Skip to main content

llm_dialect/dialect/anthropic/
stream.rs

1//! Canonical chunk stream → Anthropic Messages SSE framing.
2//!
3//! Moved from the legacy `anthropic_in.rs`; owns everything between the
4//! pipeline's `CanonChunk` stream and the client-visible `event:`/`data:`
5//! frames: block-index assignment (thinking/text/tool blocks reopen at fresh
6//! indices per Anthropic's stream protocol), usage repair, and the guaranteed
7//! terminal `message_delta`/`message_stop`. The framer core is pure —
8//! `CanonChunk` in, frame pairs out, no I/O; the pump is the shared SSE
9//! driver (`dialect::sse`), and this file's `anthropic_stream_response` is
10//! the thin axum shell wiring state machine to pump.
11
12use crate::canonical::{CanonChunk, Usage};
13#[cfg(feature = "axum")]
14use crate::error::ProxyError;
15#[cfg(feature = "axum")]
16use futures::Stream;
17
18/// Sliding window that recognises a client stop sequence inside the streamed
19/// text and keeps it from reaching the client.
20///
21/// A stop sequence can straddle delta boundaries, so the last `max_len - 1`
22/// characters of the open block are withheld until either more text arrives
23/// or the block closes. Only ever constructed when the request carried stop
24/// sequences — the common path never allocates or copies.
25#[derive(Debug, Default)]
26pub struct StopWindow {
27    seqs: Vec<String>,
28    max_len: usize,
29    /// characters withheld from `block`, still possibly a partial match
30    tail: String,
31    block: Option<usize>,
32    kind: &'static str,
33    /// the sequence that fired, once one has
34    pub matched: Option<String>,
35}
36
37impl StopWindow {
38    fn new(seqs: Vec<String>) -> Option<Self> {
39        let seqs: Vec<String> = seqs.into_iter().filter(|s| !s.is_empty()).collect();
40        let max_len = seqs.iter().map(|s| s.chars().count()).max()?;
41        Some(StopWindow {
42            seqs,
43            max_len,
44            tail: String::new(),
45            block: None,
46            kind: "",
47            matched: None,
48        })
49    }
50
51    /// Feed one delta; returns the prefix that is safe to emit now. Once a
52    /// stop has fired everything after it is swallowed — the upstream keeps
53    /// streaming its own trailing frames, but Anthropic's contract is that the
54    /// turn ended at the sequence.
55    fn feed(&mut self, idx: usize, kind: &'static str, incoming: &str) -> String {
56        if self.matched.is_some() {
57            return String::new();
58        }
59        if self.block != Some(idx) {
60            // caller flushes the previous block before it closes
61            self.block = Some(idx);
62            self.kind = kind;
63            self.tail.clear();
64        }
65        let mut buf = std::mem::take(&mut self.tail);
66        buf.push_str(incoming);
67        for s in &self.seqs {
68            if let Some(pos) = buf.find(s.as_str()) {
69                self.matched = Some(s.clone());
70                buf.truncate(pos);
71                return buf;
72            }
73        }
74        let keep = self.max_len.saturating_sub(1);
75        let split = buf
76            .char_indices()
77            .rev()
78            .take(keep)
79            .last()
80            .map(|(i, _)| i)
81            .unwrap_or(buf.len());
82        self.tail = buf.split_off(split);
83        buf
84    }
85
86    /// Withheld text for the still-open block, surrendered because that block
87    /// is about to close without a stop ever firing.
88    fn take_tail(&mut self) -> Option<(usize, &'static str, String)> {
89        let idx = self.block?;
90        if self.tail.is_empty() {
91            return None;
92        }
93        Some((idx, self.kind, std::mem::take(&mut self.tail)))
94    }
95}
96
97pub struct StreamState {
98    /// gates the `message_start` preamble
99    pub first: bool,
100    /// per-content-block open/closed, indexed by block index
101    pub blocks: Vec<bool>,
102    /// fixed index for the upstream thinking block, once opened.
103    /// Thinking uses indices 0..max_thinking_index; everything else opens after.
104    pub thinking_index: Option<usize>,
105    /// index the upstream `content` text started at; parallel tool calls come
106    /// after this. None until first text delta arrives.
107    pub text_index: Option<usize>,
108    /// where parallel tool calls start; runs after any thinking/text blocks
109    /// that have already been opened.
110    pub tool_base_index: Option<usize>,
111    /// known-at-preamble input tokens carried through from the upstream
112    /// `message_start` chunk (Anthropic) — 0 until the first payload arrives
113    pub input_tokens: u64,
114    pub stop_reason: Option<String>,
115    /// usage from a choice-less trailer chunk that arrived BEFORE any finish
116    /// chunk: buffered here and flushed with the terminal message_delta,
117    /// instead of terminating the stream early.
118    pub pending_usage: Option<TerminalUsage>,
119    pub message_stopped: bool,
120    /// Present only when the request carried stop sequences.
121    pub stop_window: Option<StopWindow>,
122}
123
124impl StreamState {
125    pub fn new() -> Self {
126        Self {
127            first: true,
128            blocks: Vec::new(),
129            thinking_index: None,
130            text_index: None,
131            tool_base_index: None,
132            input_tokens: 0,
133            stop_reason: None,
134            pending_usage: None,
135            message_stopped: false,
136            stop_window: None,
137        }
138    }
139
140    /// State for a request carrying Anthropic `stop_sequences`.
141    pub fn with_stop_sequences(seqs: Vec<String>) -> Self {
142        Self {
143            stop_window: StopWindow::new(seqs),
144            ..Self::new()
145        }
146    }
147}
148
149impl Default for StreamState {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155/// The four usage counters as they appear on a canonical chunk's usage block
156/// (input/completion/cached-read/cache-write), named because they recur at
157/// several terminal-frame sites. `pending_usage` uses this; compute paths may
158/// also build it ad hoc from a fresh usage payload.
159#[derive(Debug, Clone, Copy, Default)]
160pub struct TerminalUsage {
161    pub input: u64,
162    pub output: u64,
163    pub cached_read: u64,
164    pub cache_write: u64,
165}
166
167impl TerminalUsage {
168    /// From the typed canonical Usage. Ignores reasoning_tokens — Anthropic's
169    /// wire has no slot for it (it's folded into output_tokens upstream).
170    pub fn from_usage(u: &Usage) -> Self {
171        Self {
172            input: u.prompt_tokens,
173            output: u.completion_tokens,
174            cached_read: u.cached_read_tokens,
175            cache_write: u.cache_write_tokens,
176        }
177    }
178    /// Anthropic's wire reports fresh-only input; canonical is cache-inclusive.
179    fn wire_input(&self, fallback: u64) -> u64 {
180        (if self.input > 0 { self.input } else { fallback })
181            .saturating_sub(self.cached_read + self.cache_write)
182    }
183}
184
185fn open_block(
186    out: &mut Vec<(String, String)>,
187    state: &mut StreamState,
188    idx: usize,
189    block: serde_json::Value,
190) {
191    flush_stop_tail(out, state);
192    // close everything below idx that is still open — SSE blocks are sequential
193    for i in 0..idx {
194        if i < state.blocks.len() && state.blocks[i] {
195            state.blocks[i] = false;
196            out.push((
197                "content_block_stop".into(),
198                serde_json::json!({"type": "content_block_stop", "index": i}).to_string(),
199            ));
200        }
201    }
202    if state.blocks.len() <= idx {
203        state.blocks.resize(idx + 1, false);
204    }
205    state.blocks[idx] = true;
206    out.push((
207        "content_block_start".into(),
208        serde_json::json!({
209            "type": "content_block_start",
210            "index": idx,
211            "content_block": block,
212        })
213        .to_string(),
214    ));
215}
216
217/// Emit any text withheld by the stop window into its own block, before that
218/// block is closed. No-op on the common (no stop sequences) path.
219fn flush_stop_tail(out: &mut Vec<(String, String)>, state: &mut StreamState) {
220    let Some((idx, kind, text)) = state.stop_window.as_mut().and_then(StopWindow::take_tail) else {
221        return;
222    };
223    if state.blocks.get(idx) != Some(&true) {
224        return;
225    }
226    let delta = if kind == "thinking" {
227        serde_json::json!({"type": "thinking_delta", "thinking": text})
228    } else {
229        serde_json::json!({"type": "text_delta", "text": text})
230    };
231    out.push((
232        "content_block_delta".into(),
233        serde_json::json!({
234            "type": "content_block_delta",
235            "index": idx,
236            "delta": delta,
237        })
238        .to_string(),
239    ));
240}
241
242fn close_block(out: &mut Vec<(String, String)>, state: &mut StreamState, upto: usize) {
243    flush_stop_tail(out, state);
244    for (i, open) in state.blocks.iter_mut().enumerate().take(upto) {
245        if *open {
246            *open = false;
247            out.push((
248                "content_block_stop".into(),
249                serde_json::json!({"type": "content_block_stop", "index": i}).to_string(),
250            ));
251        }
252    }
253}
254
255/// Returns the next free block index. Slots are dedicated: thinking→0..N,
256/// text after that, tools after that. Never reuse an index in one stream.
257fn next_index(state: &StreamState) -> usize {
258    state.blocks.len()
259}
260
261/// Terminal `message_delta` usage. Output tokens always; input tokens
262/// whenever known — for non-Anthropic upstreams the prompt count only ever
263/// arrives in the trailer, and this frame is the sole place it can surface.
264/// Cache counters ride along when present (clients bill on them).
265fn terminal_usage(
266    input: u64,
267    output: u64,
268    cached_read: u64,
269    cache_write: u64,
270) -> serde_json::Value {
271    let mut u = serde_json::json!({
272        "input_tokens": input,
273        "output_tokens": output,
274    });
275    if cached_read > 0 {
276        u["cache_read_input_tokens"] = serde_json::json!(cached_read);
277    }
278    if cache_write > 0 {
279        u["cache_creation_input_tokens"] = serde_json::json!(cache_write);
280    }
281    u
282}
283
284/// Terminal `message_delta` + `message_stop` carrying a mapped stop reason
285/// and usage. Shared by the trailer, finish, and finalizer paths so the
286/// exactly-one-terminal-frame invariant lives in one place.
287fn emit_terminal(
288    out: &mut Vec<(String, String)>,
289    state: &mut StreamState,
290    stop_reason: String,
291    usage: TerminalUsage,
292) {
293    close_block(out, state, state.blocks.len());
294    let prompt = usage.wire_input(state.input_tokens);
295    // A stop sequence that actually fired outranks the upstream's verdict:
296    // OpenAI-dialect backends report it as a plain "stop", indistinguishable
297    // from running out of things to say.
298    let matched = state.stop_window.as_ref().and_then(|w| w.matched.clone());
299    let (stop_reason, stop_sequence) = match matched {
300        Some(s) => ("stop_sequence".to_string(), serde_json::json!(s)),
301        None => (stop_reason, serde_json::Value::Null),
302    };
303    out.push((
304        "message_delta".into(),
305        serde_json::json!({
306            "type": "message_delta",
307            "delta": {
308                "stop_reason": stop_reason,
309                "stop_sequence": stop_sequence,
310            },
311            "usage": terminal_usage(prompt, usage.output, usage.cached_read, usage.cache_write),
312        })
313        .to_string(),
314    ));
315    out.push((
316        "message_stop".into(),
317        serde_json::json!({"type": "message_stop"}).to_string(),
318    ));
319    state.message_stopped = true;
320}
321
322/// One canonical chunk → zero or more `event:`/`data:` frame pairs.
323///
324/// The framer consumes the typed `CanonChunk` directly (no serialize →
325/// parse → mutate → re-serialize round-trip). Usage and finish_reason ride
326/// the same chunk, so Anthropic's terminal `message_delta` can carry both
327/// (the only place usage lands on that wire).
328pub fn chunk_to_sse_events(
329    chunk: &CanonChunk,
330    model: &str,
331    state: &mut StreamState,
332    msg_id: &str,
333) -> Vec<(String, String)> {
334    if state.message_stopped {
335        return Vec::new();
336    }
337    let mut out = Vec::new();
338
339    // Anthropic upstream reports the prompt size in its message_start chunk;
340    // surface it in our own preamble. Must be read BEFORE the first-chunk
341    // message_start emission below — the preamble carries the count, and the
342    // Anthropic SDK reads message_start.usage.input_tokens.
343    if let Some(n) = chunk.input_tokens.filter(|n| *n > 0) {
344        state.input_tokens = n;
345    }
346
347    if state.first {
348        state.first = false;
349        out.push((
350            "message_start".into(),
351            serde_json::json!({
352                "type": "message_start",
353                "message": {
354                    "id": msg_id,
355                    "type": "message",
356                    "role": "assistant",
357                    "model": model,
358                    "content": [],
359                    "stop_reason": null,
360                    "stop_sequence": null,
361                    "usage": {"input_tokens": state.input_tokens, "output_tokens": 0},
362                }
363            })
364            .to_string(),
365        ));
366    }
367
368    // Usage-only trailer chunk (no content of any kind — the gemini shape
369    // attaches usage to text-bearing chunks, which must still flow through the
370    // normal handlers). A chunk carrying BOTH finish_reason and usage falls
371    // through to the finish handler below and terminates in the terminal
372    // block there.
373    let is_trailer = chunk.finish_reason.is_none()
374        && chunk.usage.is_some()
375        && chunk.delta_text.is_empty()
376        && chunk.thinking.is_none()
377        && chunk.tool_calls.is_none();
378    let merged_usage = chunk.usage.as_ref().map(TerminalUsage::from_usage);
379    if is_trailer {
380        let Some(sr) = state.stop_reason.take() else {
381            // No finish chunk seen yet — this is a mid-stream usage ping (some
382            // vLLM builds emit these), not the stream terminator. Buffer the
383            // usage for the eventual terminal frame and keep going.
384            if let Some(u) = merged_usage {
385                state.pending_usage = Some(u);
386            }
387            return out;
388        };
389        // trailer AFTER a finish chunk: it carries the terminal usage.
390        let usage = merged_usage
391            .or(state.pending_usage.take())
392            .unwrap_or_default();
393        emit_terminal(&mut out, state, sr, usage);
394        return out;
395    }
396
397    // Extended-thinking deltas: thinking takes a fresh block the first time it
398    // appears. If a later block (text/tool) was opened since and closed the
399    // thinking block, a renewed thinking delta must open a NEW block — reusing
400    // the closed index violates Anthropic's stream protocol.
401    if let Some(th) = &chunk.thinking {
402        let idx = match state.thinking_index {
403            Some(i) if state.blocks.get(i) == Some(&true) => i,
404            _ => {
405                let i = next_index(state);
406                state.thinking_index = Some(i);
407                open_block(
408                    &mut out,
409                    state,
410                    i,
411                    serde_json::json!({"type": "thinking", "thinking": ""}),
412                );
413                i
414            }
415        };
416        // Reasoning models apply stop sequences to the thinking channel too,
417        // so it is filtered exactly like visible text. Signatures are opaque
418        // and never scanned.
419        let delta_obj = match th.kind {
420            "signature" => {
421                Some(serde_json::json!({"type": "signature_delta", "signature": th.text}))
422            }
423            _ => match state.stop_window.as_mut() {
424                Some(w) => {
425                    let emit = w.feed(idx, "thinking", &th.text);
426                    (!emit.is_empty())
427                        .then(|| serde_json::json!({"type": "thinking_delta", "thinking": emit}))
428                }
429                None => Some(serde_json::json!({"type": "thinking_delta", "thinking": th.text})),
430            },
431        };
432        if let Some(delta_obj) = delta_obj {
433            out.push((
434                "content_block_delta".into(),
435                serde_json::json!({
436                    "type": "content_block_delta",
437                    "index": idx,
438                    "delta": delta_obj,
439                })
440                .to_string(),
441            ));
442        }
443    }
444    if !chunk.delta_text.is_empty() {
445        // Same reopen rule as thinking: if a tool block opened after text and
446        // closed it, resumed text gets a fresh block index.
447        let idx = match state.text_index {
448            Some(i) if state.blocks.get(i) == Some(&true) => i,
449            _ => {
450                let i = next_index(state);
451                state.text_index = Some(i);
452                open_block(
453                    &mut out,
454                    state,
455                    i,
456                    serde_json::json!({"type": "text", "text": ""}),
457                );
458                i
459            }
460        };
461        let emit = match state.stop_window.as_mut() {
462            Some(w) => w.feed(idx, "text", &chunk.delta_text),
463            None => chunk.delta_text.clone(),
464        };
465        if !emit.is_empty() {
466            out.push((
467                "content_block_delta".into(),
468                serde_json::json!({
469                    "type": "content_block_delta",
470                    "index": idx,
471                    "delta": {"type": "text_delta", "text": emit},
472                })
473                .to_string(),
474            ));
475        }
476    }
477    // Tool-call deltas: canonical streams them OpenAI-style. Each upstream
478    // `index` gets a stable anthropic block index, running right after any
479    // thinking + text blocks that already opened.
480    if let Some(tcs) = chunk.tool_calls.as_ref().and_then(|t| t.as_array()) {
481        if state.tool_base_index.is_none() {
482            state.tool_base_index = Some(next_index(state));
483        }
484        let base = state.tool_base_index.unwrap_or(0);
485        for tc in tcs {
486            let idx = tc["index"].as_u64().unwrap_or(0) as usize + base;
487            let tc_id = tc["id"].as_str().filter(|s| !s.is_empty());
488            if let Some(name) = tc["function"]["name"].as_str() {
489                // Anthropic requires a stable tool_use id that is unique
490                // across the conversation; see anthropic_tool_use_id.
491                let id = anthropic_tool_use_id(
492                    tc_id,
493                    msg_id,
494                    tc["index"].as_u64().unwrap_or(0) as usize,
495                );
496                open_block(
497                    &mut out,
498                    state,
499                    idx,
500                    serde_json::json!({"type": "tool_use", "id": id, "name": name, "input": {}}),
501                );
502            }
503            if let Some(args) = tc["function"]["arguments"].as_str()
504                && !args.is_empty()
505            {
506                if state.blocks.get(idx) != Some(&true) {
507                    // Also fires when a later text/thinking block reopened
508                    // after the tool block closed it: late args can't be
509                    // emitted validly (a reopened block would restart partial
510                    // JSON), so we drop — loudly.
511                    tracing::warn!(
512                        index = idx,
513                        "dropping tool argument delta for closed/unopened content block"
514                    );
515                    continue;
516                }
517                out.push((
518                    "content_block_delta".into(),
519                    serde_json::json!({
520                        "type": "content_block_delta",
521                        "index": idx,
522                        "delta": {"type": "input_json_delta", "partial_json": args},
523                    })
524                    .to_string(),
525                ));
526            }
527        }
528    }
529    if let Some(fr) = &chunk.finish_reason {
530        let sr = map_stop_reason_outbound(fr).to_string();
531        if let Some(usage) = merged_usage {
532            // usage rode along on the finish chunk: emit the single terminal
533            // frame carrying both stop_reason and usage (spec: exactly one
534            // message_delta per stream).
535            emit_terminal(&mut out, state, sr, usage);
536        } else {
537            // No usage yet — the trailer (or finalize_stream) will emit the
538            // terminal frame; stash the mapped reason for it.
539            state.stop_reason = Some(sr);
540        }
541    }
542    out
543}
544
545/// Client-facing Anthropic `tool_use` id.
546///
547/// Anthropic guarantees tool-use ids are unique *within a conversation* —
548/// clients key pending tool calls by id, so a repeat is dropped as a duplicate
549/// and its tool never runs. Upstreams are not bound by that: OpenAI-dialect
550/// backends have been seen returning per-message counters (`Read:0`) that
551/// collide the moment the same tool is called again on a later turn. Anything
552/// that is not already an Anthropic id is therefore namespaced by the message
553/// id, which is unique per response and so unique across the conversation.
554pub(super) fn anthropic_tool_use_id(
555    upstream_id: Option<&str>,
556    msg_id: &str,
557    index: usize,
558) -> String {
559    // The message id is `chatcmpl-<unique>` on OpenAI-dialect upstreams; only
560    // the unique half earns its place in a client-facing id.
561    let msg_id = msg_id.strip_prefix("chatcmpl-").unwrap_or(msg_id);
562    match upstream_id.filter(|s| !s.is_empty()) {
563        // Genuine Anthropic passthrough: already unique, kept verbatim so
564        // round trips stay byte-identical.
565        Some(id) if id.starts_with("toolu_") => id.to_string(),
566        Some(id) => {
567            let slug: String = id
568                .chars()
569                .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
570                .collect();
571            format!("toolu_x_{msg_id}_{slug}")
572        }
573        None => format!("toolu_x_{msg_id}_{index}"),
574    }
575}
576
577pub(super) fn map_stop_reason_outbound(fr: &str) -> &str {
578    match fr {
579        "stop" => "end_turn",
580        "length" => "max_tokens",
581        "tool_calls" => "tool_use",
582        // OpenAI/Gemini safety-stop reason has no direct Anthropic analogue;
583        // refusal is the closest fit (model declined rather than completed).
584        "content_filter" => "refusal",
585        // spec-tolerated pass-through so newer Claude stop reasons keep working
586        known @ ("end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "pause_turn"
587        | "refusal") => known,
588        // unknown → end_turn (strict SDKs reject unrecognised stop reasons)
589        _ => "end_turn",
590    }
591}
592
593/// Terminal frames for a stream that ended without a finish chunk (provider
594/// truncated, client-visible end closer). Mirrors the OpenAI surface's
595/// guaranteed `[DONE]`. No-op when the message already stopped.
596pub fn finalize_stream(state: &mut StreamState) -> Vec<(String, String)> {
597    if state.message_stopped {
598        return Vec::new();
599    }
600    state.message_stopped = true;
601    if state.first {
602        // never emitted anything — nothing to finalize
603        return Vec::new();
604    }
605    let mut out = Vec::new();
606    let usage = state.pending_usage.take().unwrap_or_default();
607    let sr = state
608        .stop_reason
609        .take()
610        .unwrap_or_else(|| "end_turn".to_string());
611    emit_terminal(&mut out, state, sr, usage);
612    out
613}
614
615/// SSE framing for the Anthropic surface over the accounted canonical stream
616/// (idle timeout, billing and circuit-breaker reporting all happen inside
617/// `LoggedStream`). The framer core above is pure; this shell only pumps.
618#[cfg(feature = "axum")]
619pub fn anthropic_stream_response<S>(
620    inner: S,
621    model: String,
622    msg_id: String,
623    stop_sequences: Vec<String>,
624) -> axum::response::Response
625where
626    S: Stream<Item = Result<CanonChunk, ProxyError>> + Unpin + Send + 'static,
627{
628    let state = std::sync::Arc::new(std::sync::Mutex::new(StreamState::with_stop_sequences(
629        stop_sequences,
630    )));
631    let state_done = state.clone();
632    crate::dialect::sse::sse_response(
633        inner,
634        move |out, item| {
635            let mut st = state.lock().unwrap();
636            match item {
637                Ok(chunk) => {
638                    for (ev, d) in chunk_to_sse_events(&chunk, &model, &mut st, &msg_id) {
639                        out.push(format!("event: {ev}\ndata: {d}\n\n"));
640                    }
641                }
642                Err(e) => {
643                    // terminal: suppress the end-of-stream finalizer
644                    st.message_stopped = true;
645                    let (_, j) = super::out::error_json(&e);
646                    out.push(format!("event: error\ndata: {j}\n\n"));
647                }
648            }
649        },
650        move |out| {
651            let mut st = state_done.lock().unwrap();
652            for (ev, d) in finalize_stream(&mut st) {
653                out.push(format!("event: {ev}\ndata: {d}\n\n"));
654            }
655        },
656    )
657}
658
659#[cfg(all(test, feature = "axum"))]
660mod tests {
661    use super::*;
662    use crate::canonical::{ThinkingDelta, Usage};
663
664    /// Drive a whole streamed turn through the framer and return the frames.
665    fn run_stream(stops: Vec<String>, chunks: Vec<CanonChunk>) -> String {
666        let mut st = StreamState::with_stop_sequences(stops);
667        let mut out = String::new();
668        for c in &chunks {
669            for (ev, d) in chunk_to_sse_events(c, "m", &mut st, "msg_1") {
670                out.push_str(&format!("event: {ev}\ndata: {d}\n\n"));
671            }
672        }
673        out
674    }
675
676    fn streamed_text(frames: &str) -> String {
677        frames
678            .lines()
679            .filter(|l| l.starts_with("data: "))
680            .filter_map(|l| serde_json::from_str::<serde_json::Value>(&l[6..]).ok())
681            .filter(|v| v["type"] == "content_block_delta")
682            .filter_map(|v| {
683                v["delta"]["text"]
684                    .as_str()
685                    .or(v["delta"]["thinking"].as_str())
686                    .map(str::to_string)
687            })
688            .collect()
689    }
690
691    fn finish_stop() -> CanonChunk {
692        CanonChunk {
693            finish_reason: Some("stop".into()),
694            usage: Some(usage(5, 5)),
695            ..Default::default()
696        }
697    }
698
699    #[test]
700    fn streamed_stop_sequence_is_reported_and_withheld() {
701        let frames = run_stream(
702            vec!["<END>".into()],
703            vec![
704                text("one two "),
705                text("<END>"),
706                text(" three"),
707                finish_stop(),
708            ],
709        );
710        assert_eq!(streamed_text(&frames), "one two ");
711        assert!(
712            frames.contains(r#""stop_reason":"stop_sequence""#),
713            "{frames}"
714        );
715        assert!(frames.contains(r#""stop_sequence":"<END>""#), "{frames}");
716    }
717
718    #[test]
719    fn stop_sequence_split_across_deltas_is_still_caught() {
720        // the sequence never appears whole in any single delta
721        let frames = run_stream(
722            vec!["<END>".into()],
723            vec![
724                text("keep"),
725                text("<E"),
726                text("N"),
727                text("D> drop"),
728                finish_stop(),
729            ],
730        );
731        assert_eq!(streamed_text(&frames), "keep");
732        assert!(frames.contains(r#""stop_sequence":"<END>""#), "{frames}");
733    }
734
735    #[test]
736    fn withheld_tail_is_flushed_when_no_stop_fires() {
737        // "<EN" looked like a partial match but the turn ended naturally —
738        // the held-back characters must still reach the client.
739        let frames = run_stream(
740            vec!["<END>".into()],
741            vec![text("all of it <EN"), finish_stop()],
742        );
743        assert_eq!(streamed_text(&frames), "all of it <EN");
744        assert!(frames.contains(r#""stop_reason":"end_turn""#), "{frames}");
745    }
746
747    #[test]
748    fn stop_sequence_in_streamed_thinking_is_caught() {
749        let th = |t: &str| CanonChunk {
750            thinking: Some(crate::canonical::ThinkingDelta {
751                kind: "thinking",
752                text: t.into(),
753                block_index: 0,
754            }),
755            ..Default::default()
756        };
757        let frames = run_stream(
758            vec!["FIVE".into()],
759            vec![th("ONE TWO "), th("FIVE SIX"), finish_stop()],
760        );
761        assert_eq!(streamed_text(&frames), "ONE TWO ");
762        assert!(frames.contains(r#""stop_sequence":"FIVE""#), "{frames}");
763    }
764
765    #[test]
766    fn no_stop_sequences_streams_byte_for_byte() {
767        let frames = run_stream(vec![], vec![text("a"), text("b"), text("c"), finish_stop()]);
768        assert_eq!(streamed_text(&frames), "abc");
769        assert!(frames.contains(r#""stop_sequence":null"#), "{frames}");
770    }
771
772    fn text(s: &str) -> CanonChunk {
773        CanonChunk {
774            delta_text: s.into(),
775            ..Default::default()
776        }
777    }
778
779    fn usage(p: u64, c: u64) -> Usage {
780        Usage {
781            prompt_tokens: p,
782            completion_tokens: c,
783            cached_read_tokens: 0,
784            cache_write_tokens: 0,
785            reasoning_tokens: None,
786        }
787    }
788
789    fn usage_with(p: u64, c: u64, cr: u64, cw: u64) -> Usage {
790        Usage {
791            prompt_tokens: p,
792            completion_tokens: c,
793            cached_read_tokens: cr,
794            cache_write_tokens: cw,
795            reasoning_tokens: None,
796        }
797    }
798
799    /// Run typed canonical chunks through the framer, return all frame pairs
800    /// including the finalizer's.
801    fn frames(chunks: Vec<CanonChunk>) -> Vec<(String, String)> {
802        let mut st = StreamState::new();
803        let mut all = Vec::new();
804        for c in &chunks {
805            all.extend(chunk_to_sse_events(c, "route-alias", &mut st, "msg_x"));
806        }
807        all.extend(finalize_stream(&mut st));
808        all
809    }
810
811    fn types(all: &[(String, String)]) -> Vec<&str> {
812        all.iter().map(|(e, _)| e.as_str()).collect()
813    }
814
815    fn data_of<'a>(all: &'a [(String, String)], ev: &str) -> Vec<&'a str> {
816        all.iter()
817            .filter(|(e, _)| e == ev)
818            .map(|(_, d)| d.as_str())
819            .collect()
820    }
821
822    fn starts_indices(all: &[(String, String)]) -> Vec<i64> {
823        data_of(all, "content_block_start")
824            .iter()
825            .filter_map(|d| serde_json::from_str::<serde_json::Value>(d).unwrap()["index"].as_i64())
826            .collect()
827    }
828
829    #[test]
830    fn stream_chunks_emit_message_start_then_text() {
831        let mut st = StreamState::new();
832        let evs = chunk_to_sse_events(&text("Hi"), "translate-model", &mut st, "msg_1");
833        assert_eq!(evs[0].0, "message_start");
834        assert!(evs.iter().any(|(t, _)| t == "content_block_delta"));
835        assert!(
836            !evs.iter().any(|(t, _)| t == "ping"),
837            "ping is one-shot preamble noise; real Anthropic streams ping periodically, not here"
838        );
839
840        let evs2 = chunk_to_sse_events(
841            &CanonChunk {
842                finish_reason: Some("stop".into()),
843                usage: Some(usage(3, 1)),
844                ..text("")
845            },
846            "translate-model",
847            &mut st,
848            "msg_1",
849        );
850        let t = types(&evs2);
851        assert!(t.contains(&"message_delta"));
852        assert!(t.contains(&"message_stop"));
853        let delta = data_of(&evs2, "message_delta")[0];
854        assert!(delta.contains("end_turn"));
855    }
856
857    #[test]
858    fn message_start_carries_preamble_input_tokens() {
859        // Anthropic upstream reports the prompt size in its message_start
860        // chunk; the preamble must carry it (the SDK reads
861        // message_start.usage.input_tokens). Regression guard: the capture
862        // must happen BEFORE the preamble emission.
863        let mut st = StreamState::new();
864        let evs = chunk_to_sse_events(
865            &CanonChunk {
866                input_tokens: Some(42),
867                ..text("")
868            },
869            "m",
870            &mut st,
871            "msg_1",
872        );
873        assert_eq!(evs[0].0, "message_start");
874        let ms = serde_json::from_str::<serde_json::Value>(&evs[0].1).unwrap();
875        assert_eq!(
876            ms["message"]["usage"]["input_tokens"], 42,
877            "preamble must carry the upstream prompt count: {ms}"
878        );
879    }
880
881    #[test]
882    fn terminal_usage_reports_fresh_only_input_tokens() {
883        // Canonical usage is cache-inclusive; Anthropic clients must see
884        // input_tokens the way Anthropic reports them (fresh tokens only).
885        let all = frames(vec![
886            text("hi"),
887            CanonChunk {
888                finish_reason: Some("stop".into()),
889                usage: Some(usage_with(18204, 7, 18000, 200)),
890                ..text("")
891            },
892        ]);
893        let md =
894            serde_json::from_str::<serde_json::Value>(data_of(&all, "message_delta")[0]).unwrap();
895        assert_eq!(md["usage"]["input_tokens"], 4);
896        assert_eq!(md["usage"]["output_tokens"], 7);
897        assert_eq!(md["usage"]["cache_read_input_tokens"], 18000);
898        assert_eq!(md["usage"]["cache_creation_input_tokens"], 200);
899    }
900
901    #[test]
902    fn anthropic_upstream_tool_stream_is_well_formed() {
903        // finish_reason + usage on one chunk (Anthropic upstream shape): exactly one
904        // message_delta carries BOTH the mapped stop_reason and the usage, each
905        // content block is stopped exactly once, and nothing is emitted after stop.
906        let all = frames(vec![
907            text("checking"),
908            CanonChunk {
909                tool_calls: Some(serde_json::json!([
910                    {"index":0,"id":"toolu_1","function":{"name":"bash","arguments":""}}
911                ])),
912                ..text("")
913            },
914            CanonChunk {
915                tool_calls: Some(serde_json::json!([
916                    {"index":0,"function":{"arguments":"{}"}}
917                ])),
918                ..text("")
919            },
920            CanonChunk {
921                finish_reason: Some("tool_calls".into()),
922                usage: Some(usage(10, 4)),
923                ..text("")
924            },
925        ]);
926        let deltas = data_of(&all, "message_delta");
927        assert_eq!(deltas.len(), 1, "exactly one message_delta: {deltas:?}");
928        assert!(deltas[0].contains("tool_use"));
929        assert!(deltas[0].contains("\"input_tokens\":10"));
930        let stops = data_of(&all, "content_block_stop");
931        assert_eq!(
932            stops.len(),
933            2,
934            "text block 0 + tool block 1 each closed once"
935        );
936        assert_eq!(data_of(&all, "message_stop").len(), 1);
937    }
938
939    #[test]
940    fn reopened_block_gets_fresh_index_when_closed() {
941        // thinking → text → thinking interleave: block 0 (thinking) was closed
942        // when text opened block 1; the resumed thinking must not reuse 0.
943        let all = frames(vec![
944            CanonChunk {
945                thinking: Some(ThinkingDelta {
946                    block_index: 0,
947                    kind: "thinking",
948                    text: "h1".into(),
949                }),
950                ..text("")
951            },
952            text("t"),
953            CanonChunk {
954                thinking: Some(ThinkingDelta {
955                    block_index: 0,
956                    kind: "thinking",
957                    text: "h2".into(),
958                }),
959                ..text("")
960            },
961        ]);
962        let indices = starts_indices(&all);
963        assert_eq!(
964            indices,
965            vec![0, 1, 2],
966            "blocks must never reuse an index: {indices:?}"
967        );
968        // every delta must target an index that was started and not yet stopped
969        let mut open: std::collections::HashSet<i64> = Default::default();
970        let mut seen_started: std::collections::HashSet<i64> = Default::default();
971        for (ev, d) in &all {
972            let v: serde_json::Value = serde_json::from_str(d).unwrap();
973            let idx = v["index"].as_i64();
974            match ev.as_str() {
975                "content_block_start" => {
976                    if let Some(i) = idx {
977                        assert!(seen_started.insert(i), "block {i} started twice");
978                        open.insert(i);
979                    }
980                }
981                "content_block_stop" => {
982                    if let Some(i) = idx {
983                        open.remove(&i);
984                    }
985                }
986                "content_block_delta" => {
987                    if let Some(i) = idx {
988                        assert!(open.contains(&i), "delta on non-open block {i}");
989                    }
990                }
991                _ => {}
992            }
993        }
994    }
995
996    #[test]
997    fn thinking_delta_streams_with_own_block_index() {
998        let mut st = StreamState::new();
999        let mut events: Vec<String> = Vec::new();
1000        for c in [
1001            CanonChunk {
1002                thinking: Some(ThinkingDelta {
1003                    block_index: 0,
1004                    kind: "thinking",
1005                    text: "let me".into(),
1006                }),
1007                ..text("")
1008            },
1009            CanonChunk {
1010                thinking: Some(ThinkingDelta {
1011                    block_index: 0,
1012                    kind: "thinking",
1013                    text: " think".into(),
1014                }),
1015                ..text("")
1016            },
1017            CanonChunk {
1018                thinking: Some(ThinkingDelta {
1019                    block_index: 0,
1020                    kind: "signature",
1021                    text: "sig123".into(),
1022                }),
1023                ..text("")
1024            },
1025            text("answer"),
1026            CanonChunk {
1027                finish_reason: Some("stop".into()),
1028                usage: Some(usage(5, 3)),
1029                ..text("")
1030            },
1031        ] {
1032            for (ev, d) in chunk_to_sse_events(&c, "m", &mut st, "msg_1") {
1033                events.push(format!("{ev}: {d}"));
1034            }
1035        }
1036        let joined = events.join("\n");
1037        assert!(
1038            joined.contains("thinking_delta"),
1039            "missing thinking_delta: {joined}"
1040        );
1041        assert!(
1042            joined.contains("signature_delta"),
1043            "missing signature_delta"
1044        );
1045        assert!(
1046            joined.contains("\"index\":0"),
1047            "thinking block should be index 0"
1048        );
1049        // thinking is block 0, so the text answer lands at index 1
1050        assert!(joined.contains("\"index\":1") && joined.contains("\"type\":\"text\""));
1051    }
1052
1053    #[test]
1054    fn usage_every_chunk_does_not_double_stop() {
1055        // Gemini-style providers attach usage to every chunk — a usage-only
1056        // chunk with no finish is a trailer, not a terminator, unless a finish
1057        // chunk already stashed a stop reason.
1058        let mut st = StreamState::new();
1059        let mut all: Vec<(String, String)> = Vec::new();
1060        for _ in 0..2 {
1061            all.extend(chunk_to_sse_events(
1062                &CanonChunk {
1063                    usage: Some(usage(5, 2)),
1064                    ..text("")
1065                },
1066                "m",
1067                &mut st,
1068                "msg_1",
1069            ));
1070        }
1071        // no finish seen: nothing emitted, usage is buffered for the terminal frame
1072        assert!(!types(&all).contains(&"message_stop"));
1073        assert!(!st.message_stopped);
1074        // the real finish chunk terminates once, carrying the buffered usage
1075        all.extend(chunk_to_sse_events(
1076            &CanonChunk {
1077                finish_reason: Some("stop".into()),
1078                ..text("")
1079            },
1080            "m",
1081            &mut st,
1082            "msg_1",
1083        ));
1084        all.extend(finalize_stream(&mut st));
1085        assert_eq!(data_of(&all, "message_stop").len(), 1);
1086        assert_eq!(data_of(&all, "message_delta").len(), 1);
1087    }
1088
1089    #[test]
1090    fn trailer_usage_carries_input_and_cache_tokens() {
1091        // non-Anthropic upstream: message_start goes out with input_tokens: 0;
1092        // the trailer must still surface prompt + cache counts in message_delta.
1093        // Canonical prompt_tokens is cache-inclusive: 151 = fresh 42 + 100 read
1094        // + 9 written; the client must see the fresh 42 plus cache counts.
1095        let mut st = StreamState::new();
1096        let mut evs = chunk_to_sse_events(&text("hi"), "m", &mut st, "msg_1");
1097        evs.extend(chunk_to_sse_events(
1098            &CanonChunk {
1099                finish_reason: Some("stop".into()),
1100                ..text("")
1101            },
1102            "m",
1103            &mut st,
1104            "msg_1",
1105        ));
1106        evs.extend(chunk_to_sse_events(
1107            &CanonChunk {
1108                usage: Some(usage_with(151, 7, 100, 9)),
1109                ..text("")
1110            },
1111            "m",
1112            &mut st,
1113            "msg_1",
1114        ));
1115        let md = serde_json::from_str::<serde_json::Value>(
1116            evs.iter()
1117                .find(|(e, _)| e == "message_delta")
1118                .map(|(_, d)| d.as_str())
1119                .unwrap(),
1120        )
1121        .unwrap();
1122        assert_eq!(md["usage"]["input_tokens"], 42);
1123        assert_eq!(md["usage"]["output_tokens"], 7);
1124        assert_eq!(md["usage"]["cache_read_input_tokens"], 100);
1125        assert_eq!(md["usage"]["cache_creation_input_tokens"], 9);
1126    }
1127
1128    /// what providers::anthropic::translate_stream emits for a tool-call turn:
1129    /// exactly one terminal message_delta with the mapped stop reason + usage.
1130    #[test]
1131    fn repro_anthropic_upstream_tool_use() {
1132        let all = frames(vec![
1133            text("Let me check"),
1134            CanonChunk {
1135                tool_calls: Some(serde_json::json!([
1136                    {"index":0,"id":"toolu_1","type":"function","function":{"name":"get_weather","arguments":""}}
1137                ])),
1138                ..text("")
1139            },
1140            CanonChunk {
1141                tool_calls: Some(serde_json::json!([
1142                    {"index":0,"function":{"arguments":"{\"city\":\"Rome\"}"}}
1143                ])),
1144                ..text("")
1145            },
1146            // message_delta from the Anthropic upstream: finish_reason AND usage
1147            CanonChunk {
1148                finish_reason: Some("tool_calls".into()),
1149                usage: Some(usage(100, 20)),
1150                ..text("")
1151            },
1152        ]);
1153        let deltas = data_of(&all, "message_delta");
1154        assert_eq!(deltas.len(), 1);
1155        assert!(
1156            deltas[0].contains("\"stop_reason\":\"tool_use\""),
1157            "{deltas:?}"
1158        );
1159        assert!(deltas[0].contains("\"input_tokens\":100"));
1160        assert_eq!(data_of(&all, "message_stop").len(), 1);
1161        let stops = data_of(&all, "content_block_stop");
1162        assert_eq!(stops.len(), 2, "text + tool blocks closed once each");
1163    }
1164
1165    #[test]
1166    fn repro_openai_upstream() {
1167        // OpenAI upstream splits finish and usage across two chunks: the finish
1168        // stashes end_turn, the usage trailer terminates with it.
1169        let all = frames(vec![
1170            text("Hi"),
1171            text(" there"),
1172            CanonChunk {
1173                finish_reason: Some("stop".into()),
1174                ..text("")
1175            },
1176            CanonChunk {
1177                usage: Some(usage(5, 2)),
1178                ..text("")
1179            },
1180        ]);
1181        let deltas = data_of(&all, "message_delta");
1182        assert_eq!(deltas.len(), 1, "{deltas:?}");
1183        assert!(
1184            deltas[0].contains("\"stop_reason\":\"end_turn\""),
1185            "{deltas:?}"
1186        );
1187        assert!(deltas[0].contains("\"input_tokens\":5"));
1188        assert!(deltas[0].contains("\"output_tokens\":2"));
1189        assert_eq!(data_of(&all, "message_stop").len(), 1);
1190    }
1191
1192    #[test]
1193    fn repro_gemini_upstream_usage_every_chunk() {
1194        // Gemini attaches usage to every chunk; the finish-less usage chunks
1195        // must buffer, and the finish chunk must terminate exactly once.
1196        let all = frames(vec![
1197            CanonChunk {
1198                usage: Some(usage(5, 2)),
1199                ..text("Hello")
1200            },
1201            CanonChunk {
1202                usage: Some(usage(5, 2)),
1203                ..text(" world")
1204            },
1205            CanonChunk {
1206                usage: Some(usage(5, 2)),
1207                finish_reason: Some("stop".into()),
1208                ..text("!")
1209            },
1210        ]);
1211        assert_eq!(data_of(&all, "message_delta").len(), 1);
1212        assert_eq!(data_of(&all, "message_stop").len(), 1);
1213        let text_deltas = data_of(&all, "content_block_delta");
1214        assert_eq!(text_deltas.len(), 3, "{text_deltas:?}");
1215    }
1216
1217    /// tool_use with args split across chunks, then finish (+usage merged).
1218    #[test]
1219    fn pure_tool_turn_indices() {
1220        let mut st = StreamState::new();
1221        let chunks = [
1222            CanonChunk {
1223                thinking: Some(ThinkingDelta {
1224                    block_index: 0,
1225                    kind: "thinking",
1226                    text: "let me check".into(),
1227                }),
1228                ..text("")
1229            },
1230            CanonChunk {
1231                tool_calls: Some(serde_json::json!([
1232                    {"index":0,"id":"call_1","type":"function","function":{"name":"Bash","arguments":""}}
1233                ])),
1234                ..text("")
1235            },
1236            CanonChunk {
1237                tool_calls: Some(serde_json::json!([
1238                    {"index":0,"function":{"arguments":"{\"command\":\"ls\"}"}}
1239                ])),
1240                ..text("")
1241            },
1242            CanonChunk {
1243                finish_reason: Some("tool_calls".into()),
1244                usage: Some(usage(100, 30)),
1245                ..text("")
1246            },
1247        ];
1248        let mut all: Vec<(String, String)> = Vec::new();
1249        for c in chunks {
1250            all.extend(chunk_to_sse_events(&c, "m", &mut st, "msg_1"));
1251        }
1252        all.extend(finalize_stream(&mut st));
1253        // block indices in order of opening: thinking then tool_use, contiguous
1254        assert_eq!(starts_indices(&all), vec![0, 1]);
1255        let stops: Vec<usize> = data_of(&all, "content_block_stop")
1256            .iter()
1257            .filter_map(|d| {
1258                serde_json::from_str::<serde_json::Value>(d).unwrap()["index"]
1259                    .as_u64()
1260                    .map(|x| x as usize)
1261            })
1262            .collect();
1263        assert_eq!(stops, vec![0, 1]);
1264        let joined = show(&all);
1265        assert!(joined.contains("\"stop_reason\":\"tool_use\""));
1266        assert_eq!(data_of(&all, "message_delta").len(), 1);
1267        assert_eq!(data_of(&all, "message_stop").len(), 1);
1268    }
1269
1270    #[test]
1271    fn mixed_text_plus_tool_in_one_upstream_chunk() {
1272        let mut st = StreamState::new();
1273        // text delta and tool_use open in the same canonical chunk
1274        let c = CanonChunk {
1275            delta_text: "Checking the code".into(),
1276            tool_calls: Some(serde_json::json!([
1277                {"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}
1278            ])),
1279            ..Default::default()
1280        };
1281        let events = chunk_to_sse_events(&c, "m", &mut st, "msg_1");
1282        assert_eq!(
1283            starts_indices(&events),
1284            vec![0, 1],
1285            "text block 0 then tool block 1, exactly once"
1286        );
1287    }
1288
1289    #[test]
1290    fn a_text_tool_text() {
1291        // text → tool → text: the trailing text after a closed text block must
1292        // open a fresh block, and tool args must not land on a closed block.
1293        let all = frames(vec![
1294            text("Let me check."),
1295            CanonChunk {
1296                tool_calls: Some(serde_json::json!([
1297                    {"index":0,"id":"call_1","type":"function","function":{"name":"Bash","arguments":"{}"}}
1298                ])),
1299                ..text("")
1300            },
1301            text(" Done."),
1302            CanonChunk {
1303                finish_reason: Some("tool_calls".into()),
1304                usage: Some(usage(10, 4)),
1305                ..text("")
1306            },
1307        ]);
1308        assert_eq!(starts_indices(&all), vec![0, 1, 2], "{:?}", show(&all));
1309        assert_eq!(data_of(&all, "message_stop").len(), 1);
1310    }
1311
1312    #[test]
1313    fn b_tool_then_text() {
1314        // tool opens first; trailing prose after it must start a new text block
1315        let all = frames(vec![
1316            CanonChunk {
1317                tool_calls: Some(serde_json::json!([
1318                    {"index":0,"id":"call_1","type":"function","function":{"name":"Bash","arguments":"{}"}}
1319                ])),
1320                ..text("")
1321            },
1322            text("trailing prose"),
1323            CanonChunk {
1324                finish_reason: Some("tool_calls".into()),
1325                usage: Some(usage(10, 4)),
1326                ..text("")
1327            },
1328        ]);
1329        assert_eq!(starts_indices(&all), vec![0, 1], "{:?}", show(&all));
1330        assert_eq!(data_of(&all, "message_stop").len(), 1);
1331    }
1332
1333    #[test]
1334    fn c_text_then_reasoning() {
1335        // openai.rs hardcodes block_index 0 for reasoning_content; the framer
1336        // must still assign its own fresh blocks (text 0, thinking 1, text 2)
1337        let all = frames(vec![
1338            text("visible"),
1339            CanonChunk {
1340                thinking: Some(ThinkingDelta {
1341                    block_index: 0,
1342                    kind: "thinking",
1343                    text: "hidden".into(),
1344                }),
1345                ..text("")
1346            },
1347            text("more"),
1348            CanonChunk {
1349                finish_reason: Some("stop".into()),
1350                usage: Some(usage(1, 1)),
1351                ..text("")
1352            },
1353        ]);
1354        assert_eq!(starts_indices(&all), vec![0, 1, 2], "{:?}", show(&all));
1355        assert_eq!(data_of(&all, "message_stop").len(), 1);
1356    }
1357
1358    fn show(all: &[(String, String)]) -> String {
1359        all.iter()
1360            .map(|(e, d)| format!("event: {e}\ndata: {d}"))
1361            .collect::<Vec<_>>()
1362            .join("\n\n")
1363    }
1364
1365    /// Mid-stream provider error, end to end through the axum shell: an Err
1366    /// item after content must emit exactly one `event: error` frame, no
1367    /// message_stop after it (the error frame is the terminator), and never
1368    /// leak the upstream error body.
1369    #[tokio::test]
1370    async fn midstream_error_terminates_with_error_frame() {
1371        use crate::error::ProxyError;
1372        let chunks: Vec<Result<CanonChunk, ProxyError>> = vec![
1373            Ok(text("partial")),
1374            Err(ProxyError::upstream(502, "secret upstream body".into())),
1375        ];
1376        let resp = anthropic_stream_response(
1377            Box::pin(futures::stream::iter(chunks)),
1378            "m".into(),
1379            "msg_1".into(),
1380            vec![],
1381        );
1382        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
1383            .await
1384            .unwrap();
1385        let s = String::from_utf8(bytes.to_vec()).unwrap();
1386        assert_eq!(
1387            s.matches("event: error").count(),
1388            1,
1389            "exactly one error frame: {s}"
1390        );
1391        let err = s
1392            .split("\n\n")
1393            .find(|f| f.starts_with("event: error"))
1394            .unwrap();
1395        assert!(err.contains("\"type\":\""), "anthropic error shape: {err}");
1396        // upstream bodies must never leak into client frames
1397        assert!(!s.contains("secret upstream body"), "body leaked: {s}");
1398        assert_eq!(
1399            s.matches("event: message_stop").count(),
1400            0,
1401            "message_stop after an error frame: {s}"
1402        );
1403        // content deltas precede the error frame
1404        assert!(
1405            s.find("text_delta").expect("no deltas") < s.find("event: error").unwrap(),
1406            "deltas must precede the error frame: {s}"
1407        );
1408    }
1409}