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