1use crate::canonical::{CanonChunk, Usage};
13#[cfg(feature = "axum")]
14use crate::error::ProxyError;
15#[cfg(feature = "axum")]
16use futures::Stream;
17
18#[derive(Debug, Default)]
26pub struct StopWindow {
27 seqs: Vec<String>,
28 max_len: usize,
29 tail: String,
31 block: Option<usize>,
32 kind: &'static str,
33 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 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 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 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 pub first: bool,
100 pub blocks: Vec<bool>,
102 pub thinking_index: Option<usize>,
105 pub text_index: Option<usize>,
108 pub tool_base_index: Option<usize>,
111 pub input_tokens: u64,
114 pub stop_reason: Option<String>,
115 pub pending_usage: Option<TerminalUsage>,
119 pub message_stopped: bool,
120 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 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#[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 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 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 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
217fn 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
255fn next_index(state: &StreamState) -> usize {
258 state.blocks.len()
259}
260
261fn 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
284fn 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 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
322pub 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 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 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 if let Some(u) = merged_usage {
385 state.pending_usage = Some(u);
386 }
387 return out;
388 };
389 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 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 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 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 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 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 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 emit_terminal(&mut out, state, sr, usage);
536 } else {
537 state.stop_reason = Some(sr);
540 }
541 }
542 out
543}
544
545pub(super) fn anthropic_tool_use_id(
555 upstream_id: Option<&str>,
556 msg_id: &str,
557 index: usize,
558) -> String {
559 let msg_id = msg_id.strip_prefix("chatcmpl-").unwrap_or(msg_id);
562 match upstream_id.filter(|s| !s.is_empty()) {
563 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 "content_filter" => "refusal",
585 known @ ("end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "pause_turn"
587 | "refusal") => known,
588 _ => "end_turn",
590 }
591}
592
593pub 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 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#[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 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 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 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 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 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 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 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 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 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 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 assert!(joined.contains("\"index\":1") && joined.contains("\"type\":\"text\""));
1051 }
1052
1053 #[test]
1054 fn usage_every_chunk_does_not_double_stop() {
1055 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 assert!(!types(&all).contains(&"message_stop"));
1073 assert!(!st.message_stopped);
1074 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 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 #[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 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 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 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 #[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 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 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 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 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 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 #[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 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 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}