1use crate::tasks::generate::ToolCall;
7use crate::TokenUsage;
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub enum StreamEvent {
19 TextDelta(String),
21 ToolCallStart {
23 name: String,
24 index: usize,
25 id: Option<String>,
26 },
27 ToolCallDelta {
29 index: usize,
30 arguments_delta: String,
31 },
32 Usage {
40 input_tokens: u64,
45 output_tokens: u64,
46 cache_read_input_tokens: u64,
50 cache_creation_input_tokens: u64,
54 },
55 StopReason(String),
61 ProviderOutputItem(serde_json::Value),
66 Error(String),
70 Done {
72 text: String,
73 tool_calls: Vec<ToolCall>,
74 },
75}
76
77pub fn parse_openai_responses_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
84 let json: serde_json::Value = match serde_json::from_str(data) {
85 Ok(v) => v,
86 Err(_) => return Vec::new(),
87 };
88 let mut events = Vec::new();
89 match event_type {
90 "response.output_text.delta" => {
91 if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
92 if !d.is_empty() {
93 events.push(StreamEvent::TextDelta(d.to_string()));
94 }
95 }
96 }
97 "response.output_item.added" => {
98 if let Some(item) = json.get("item") {
99 if item.get("type").and_then(|t| t.as_str()) == Some("function_call") {
100 let name = item
101 .get("name")
102 .and_then(|n| n.as_str())
103 .unwrap_or("")
104 .to_string();
105 let id = item
106 .get("call_id")
107 .or_else(|| item.get("id"))
108 .and_then(|i| i.as_str())
109 .map(|s| s.to_string());
110 let index = json
111 .get("output_index")
112 .and_then(|v| v.as_u64())
113 .unwrap_or(0) as usize;
114 if !name.is_empty() {
115 events.push(StreamEvent::ToolCallStart { name, index, id });
116 }
117 }
118 }
119 }
120 "response.function_call_arguments.delta" => {
121 if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
122 let index = json
123 .get("output_index")
124 .and_then(|v| v.as_u64())
125 .unwrap_or(0) as usize;
126 events.push(StreamEvent::ToolCallDelta {
127 index,
128 arguments_delta: d.to_string(),
129 });
130 }
131 }
132 "response.output_item.done" => {
133 if let Some(item) = json.get("item") {
134 if item.get("type").and_then(|value| value.as_str()) == Some("reasoning") {
135 events.push(StreamEvent::ProviderOutputItem(item.clone()));
136 }
137 }
138 }
139 "response.completed" | "response.incomplete" => {
140 if let Some(resp) = json.get("response") {
141 if let Some(u) = resp.get("usage") {
142 let input_total = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
146 let cached = u
147 .get("input_tokens_details")
148 .and_then(|d| d.get("cached_tokens"))
149 .and_then(|v| v.as_u64())
150 .unwrap_or(0)
151 .min(input_total);
152 events.push(StreamEvent::Usage {
153 input_tokens: input_total - cached,
154 output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0),
155 cache_read_input_tokens: cached,
156 cache_creation_input_tokens: 0,
157 });
158 }
159 if event_type == "response.incomplete" {
160 let reason = resp
161 .pointer("/incomplete_details/reason")
162 .and_then(|r| r.as_str())
163 .unwrap_or("incomplete");
164 events.push(StreamEvent::StopReason(reason.to_string()));
165 events.push(StreamEvent::Error(format!(
166 "managed inference incomplete: {reason}"
167 )));
168 } else {
169 events.push(StreamEvent::Done {
174 text: String::new(),
175 tool_calls: Vec::new(),
176 });
177 }
178 }
179 }
180 "error" | "response.failed" => {
181 let pick = |field: &str| {
182 json.pointer(&format!("/error/{field}"))
183 .or_else(|| json.pointer(&format!("/response/error/{field}")))
184 .and_then(|value| value.as_str())
185 .map(str::trim)
186 .filter(|value| !value.is_empty())
187 .map(str::to_string)
188 };
189 let message = pick("message");
190 let kind = pick("type");
204 let code = pick("code");
205 let mut detail = message.unwrap_or_else(|| "managed inference failed".to_string());
206 let tags: Vec<String> = [("type", kind), ("code", code)]
207 .into_iter()
208 .filter_map(|(label, value)| value.map(|v| format!("{label}={v}")))
209 .collect();
210 if !tags.is_empty() {
211 detail.push_str(&format!(" ({})", tags.join(", ")));
212 }
213 events.push(StreamEvent::Error(detail));
217 }
218 _ => {}
219 }
220 events
221}
222
223pub fn error_tags(detail: &str) -> (Option<&str>, Option<&str>) {
235 let Some(open) = detail.rfind(" (") else {
236 return (None, None);
237 };
238 let Some(close) = detail[open..].rfind(')') else {
239 return (None, None);
240 };
241 let mut kind = None;
242 let mut code = None;
243 for part in detail[open + 2..open + close].split(", ") {
244 if let Some(v) = part.strip_prefix("type=") {
245 kind = Some(v);
246 } else if let Some(v) = part.strip_prefix("code=") {
247 code = Some(v);
248 }
249 }
250 (kind, code)
251}
252
253pub fn content_refusal_tags(detail: &str) -> Option<(Option<String>, Option<String>)> {
277 let (kind, code) = error_tags(detail);
278 let refused = |v: &str| {
279 let v = v.to_ascii_lowercase();
280 v.contains("content_policy")
281 || v.contains("content_filter")
282 || v.contains("moderation")
283 || v.contains("safety")
284 };
285 (code.is_some_and(refused) || kind.is_some_and(refused))
286 .then(|| (kind.map(str::to_string), code.map(str::to_string)))
287}
288
289pub fn parse_google_sse_line(data: &str) -> Vec<StreamEvent> {
297 let json: serde_json::Value = match serde_json::from_str(data) {
298 Ok(v) => v,
299 Err(_) => return Vec::new(),
300 };
301 let mut events = Vec::new();
302 if let Some(parts) = json
303 .pointer("/candidates/0/content/parts")
304 .and_then(|p| p.as_array())
305 {
306 for (i, part) in parts.iter().enumerate() {
307 if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
308 if !t.is_empty() {
309 events.push(StreamEvent::TextDelta(t.to_string()));
310 }
311 }
312 if let Some(fc) = part.get("functionCall") {
313 let name = fc
314 .get("name")
315 .and_then(|n| n.as_str())
316 .unwrap_or("")
317 .to_string();
318 if !name.is_empty() {
319 let args = fc
320 .get("args")
321 .map(|a| a.to_string())
322 .unwrap_or_else(|| "{}".to_string());
323 events.push(StreamEvent::ToolCallStart {
324 name,
325 index: i,
326 id: None,
327 });
328 events.push(StreamEvent::ToolCallDelta {
329 index: i,
330 arguments_delta: args,
331 });
332 }
333 }
334 }
335 }
336 if let Some(u) = json.get("usageMetadata") {
337 events.push(StreamEvent::Usage {
338 input_tokens: u
339 .get("promptTokenCount")
340 .and_then(|v| v.as_u64())
341 .unwrap_or(0),
342 output_tokens: u
343 .get("candidatesTokenCount")
344 .and_then(|v| v.as_u64())
345 .unwrap_or(0),
346 cache_read_input_tokens: 0,
348 cache_creation_input_tokens: 0,
349 });
350 }
351 if let Some(fr) = json
352 .pointer("/candidates/0/finishReason")
353 .and_then(|r| r.as_str())
354 {
355 events.push(StreamEvent::StopReason(fr.to_string()));
356 }
357 events
358}
359
360pub fn parse_openai_sse_line(line: &str) -> Vec<StreamEvent> {
363 let data = match line.strip_prefix("data: ") {
364 Some(d) => d,
365 None => return Vec::new(),
366 };
367 if data == "[DONE]" {
368 return Vec::new();
369 }
370
371 let json: serde_json::Value = match serde_json::from_str(data) {
372 Ok(v) => v,
373 Err(_) => return Vec::new(),
374 };
375
376 let mut events = Vec::new();
377
378 if let Some(reason) = json
382 .get("choices")
383 .and_then(|c| c.as_array())
384 .and_then(|c| c.first())
385 .and_then(|c| c.get("finish_reason"))
386 .and_then(|r| r.as_str())
387 {
388 if !reason.is_empty() {
389 events.push(StreamEvent::StopReason(reason.to_string()));
390 }
391 }
392
393 if let Some(delta) = json
396 .get("choices")
397 .and_then(|c| c.as_array())
398 .and_then(|c| c.first())
399 .and_then(|c| c.get("delta"))
400 {
401 if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
402 if !content.is_empty() {
403 events.push(StreamEvent::TextDelta(content.to_string()));
404 }
405 }
406
407 if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
409 for tc in tool_calls {
410 let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
411 if let Some(function) = tc.get("function") {
412 if let Some(name) = function.get("name").and_then(|n| n.as_str()) {
413 let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
414 events.push(StreamEvent::ToolCallStart {
415 name: name.to_string(),
416 index,
417 id,
418 });
419 }
420 if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) {
421 if !args.is_empty() {
422 events.push(StreamEvent::ToolCallDelta {
423 index,
424 arguments_delta: args.to_string(),
425 });
426 }
427 }
428 }
429 }
430 }
431 }
432
433 if let Some(usage) = json.get("usage") {
437 let input = usage
438 .get("prompt_tokens")
439 .and_then(|n| n.as_u64())
440 .unwrap_or(0);
441 let output = usage
442 .get("completion_tokens")
443 .and_then(|n| n.as_u64())
444 .unwrap_or(0);
445 let cached = usage
448 .get("prompt_tokens_details")
449 .and_then(|d| d.get("cached_tokens"))
450 .and_then(|n| n.as_u64())
451 .unwrap_or(0)
452 .min(input);
453 if input != 0 || output != 0 {
454 events.push(StreamEvent::Usage {
455 input_tokens: input - cached,
456 output_tokens: output,
457 cache_read_input_tokens: cached,
458 cache_creation_input_tokens: 0,
459 });
460 }
461 }
462
463 events
464}
465
466pub fn parse_anthropic_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
468 match event_type {
469 "content_block_delta" => {
470 let json: serde_json::Value = match serde_json::from_str(data) {
471 Ok(v) => v,
472 Err(_) => return Vec::new(),
473 };
474 let delta = match json.get("delta") {
475 Some(d) => d,
476 None => return Vec::new(),
477 };
478 let delta_type = match delta.get("type").and_then(|t| t.as_str()) {
479 Some(t) => t,
480 None => return Vec::new(),
481 };
482
483 match delta_type {
484 "text_delta" => match delta.get("text").and_then(|t| t.as_str()) {
485 Some(text) => vec![StreamEvent::TextDelta(text.to_string())],
486 None => Vec::new(),
487 },
488 "input_json_delta" => match delta.get("partial_json").and_then(|p| p.as_str()) {
489 Some(partial) => {
490 let index =
491 json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
492 vec![StreamEvent::ToolCallDelta {
493 index,
494 arguments_delta: partial.to_string(),
495 }]
496 }
497 None => Vec::new(),
498 },
499 _ => Vec::new(),
500 }
501 }
502 "content_block_start" => {
503 let json: serde_json::Value = match serde_json::from_str(data) {
504 Ok(v) => v,
505 Err(_) => return Vec::new(),
506 };
507 let block = match json.get("content_block") {
508 Some(b) => b,
509 None => return Vec::new(),
510 };
511 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
512 if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
513 let index = json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
514 let id = block
515 .get("id")
516 .and_then(|i| i.as_str())
517 .map(|s| s.to_string());
518 return vec![StreamEvent::ToolCallStart {
519 name: name.to_string(),
520 index,
521 id,
522 }];
523 }
524 }
525 Vec::new()
526 }
527 "message_start" => {
531 let json: serde_json::Value = match serde_json::from_str(data) {
532 Ok(v) => v,
533 Err(_) => return Vec::new(),
534 };
535 let Some(usage) = json.pointer("/message/usage") else {
536 return Vec::new();
537 };
538 let input = usage
539 .get("input_tokens")
540 .and_then(|n| n.as_u64())
541 .unwrap_or(0);
542 let output = usage
543 .get("output_tokens")
544 .and_then(|n| n.as_u64())
545 .unwrap_or(0);
546 let cache_read = usage
549 .get("cache_read_input_tokens")
550 .and_then(|n| n.as_u64())
551 .unwrap_or(0);
552 let cache_creation = usage
553 .get("cache_creation_input_tokens")
554 .and_then(|n| n.as_u64())
555 .unwrap_or(0);
556 if input == 0 && output == 0 && cache_read == 0 && cache_creation == 0 {
557 return Vec::new();
558 }
559 vec![StreamEvent::Usage {
560 input_tokens: input,
561 output_tokens: output,
562 cache_read_input_tokens: cache_read,
563 cache_creation_input_tokens: cache_creation,
564 }]
565 }
566 "message_delta" => {
570 let json: serde_json::Value = match serde_json::from_str(data) {
571 Ok(v) => v,
572 Err(_) => return Vec::new(),
573 };
574 let mut events = Vec::new();
575 if let Some(reason) = json.pointer("/delta/stop_reason").and_then(|r| r.as_str()) {
578 if !reason.is_empty() {
579 events.push(StreamEvent::StopReason(reason.to_string()));
580 }
581 }
582 if let Some(usage) = json.get("usage") {
583 let input = usage
584 .get("input_tokens")
585 .and_then(|n| n.as_u64())
586 .unwrap_or(0);
587 let output = usage
588 .get("output_tokens")
589 .and_then(|n| n.as_u64())
590 .unwrap_or(0);
591 if input != 0 || output != 0 {
592 events.push(StreamEvent::Usage {
593 input_tokens: input,
594 output_tokens: output,
595 cache_read_input_tokens: 0,
598 cache_creation_input_tokens: 0,
599 });
600 }
601 }
602 events
603 }
604 _ => Vec::new(),
605 }
606}
607
608#[derive(Default)]
610pub struct StreamAccumulator {
611 pub text: String,
612 tool_names: HashMap<usize, String>,
613 tool_args: HashMap<usize, String>,
614 tool_ids: HashMap<usize, String>,
615 input_tokens: u64,
620 output_tokens: u64,
624 cache_read_input_tokens: u64,
626 cache_creation_input_tokens: u64,
628 saw_usage: bool,
633 stop_reason: Option<String>,
636 provider_output_items: Vec<serde_json::Value>,
638}
639
640impl StreamAccumulator {
641 pub fn push(&mut self, event: &StreamEvent) {
642 match event {
643 StreamEvent::TextDelta(t) => self.text.push_str(t),
644 StreamEvent::ToolCallStart { name, index, id } => {
645 self.tool_names.insert(*index, name.clone());
646 self.tool_args.entry(*index).or_default();
647 if let Some(id) = id {
648 self.tool_ids.insert(*index, id.clone());
649 }
650 }
651 StreamEvent::ToolCallDelta {
652 index,
653 arguments_delta,
654 } => {
655 self.tool_args
656 .entry(*index)
657 .or_default()
658 .push_str(arguments_delta);
659 }
660 StreamEvent::Usage {
661 input_tokens,
662 output_tokens,
663 cache_read_input_tokens,
664 cache_creation_input_tokens,
665 } => {
666 self.saw_usage = true;
667 if *input_tokens > self.input_tokens {
674 self.input_tokens = *input_tokens;
675 }
676 if *output_tokens > self.output_tokens {
677 self.output_tokens = *output_tokens;
678 }
679 if *cache_read_input_tokens > self.cache_read_input_tokens {
680 self.cache_read_input_tokens = *cache_read_input_tokens;
681 }
682 if *cache_creation_input_tokens > self.cache_creation_input_tokens {
683 self.cache_creation_input_tokens = *cache_creation_input_tokens;
684 }
685 }
686 StreamEvent::StopReason(reason) => {
687 self.stop_reason = Some(reason.clone());
688 }
689 StreamEvent::ProviderOutputItem(item) => {
690 self.provider_output_items.push(item.clone());
691 }
692 StreamEvent::Error(_) => {}
693 StreamEvent::Done { .. } => {}
694 }
695 }
696
697 pub fn finish(self) -> (String, Vec<ToolCall>) {
698 let (text, tool_calls, _, _) = self.finish_with_usage();
699 (text, tool_calls)
700 }
701
702 pub fn finish_with_usage(self) -> (String, Vec<ToolCall>, Option<TokenUsage>, Option<String>) {
710 let (text, tool_calls, usage, stop_reason, _) = self.finish_with_provider_output_items();
711 (text, tool_calls, usage, stop_reason)
712 }
713
714 pub fn finish_with_provider_output_items(
717 self,
718 ) -> (
719 String,
720 Vec<ToolCall>,
721 Option<TokenUsage>,
722 Option<String>,
723 Vec<serde_json::Value>,
724 ) {
725 let mut tool_calls = Vec::new();
726 let mut indices: Vec<usize> = self.tool_names.keys().copied().collect();
727 indices.sort();
728
729 for idx in indices {
730 let id = self.tool_ids.get(&idx).cloned();
731 let name = self.tool_names.get(&idx).cloned().unwrap_or_default();
732 let args_str = self.tool_args.get(&idx).cloned().unwrap_or_default();
733 let arguments: HashMap<String, serde_json::Value> =
734 serde_json::from_str(&args_str).unwrap_or_default();
735 tool_calls.push(ToolCall {
736 id,
737 name,
738 arguments,
739 });
740 }
741
742 let usage = if self.saw_usage {
743 Some(TokenUsage {
744 prompt_tokens: self.input_tokens,
745 completion_tokens: self.output_tokens,
746 total_tokens: self.input_tokens + self.output_tokens,
747 context_window: 0,
751 cache_read_input_tokens: self.cache_read_input_tokens,
754 cache_creation_input_tokens: self.cache_creation_input_tokens,
755 })
756 } else {
757 None
758 };
759
760 let (text, tag_calls) = crate::tasks::generate::parse_tool_calls(&self.text);
766 let (text, tool_calls) = if tool_calls.is_empty() && !tag_calls.is_empty() {
767 (text, tag_calls)
768 } else {
769 (text, tool_calls)
771 };
772
773 (
774 text,
775 tool_calls,
776 usage,
777 self.stop_reason,
778 self.provider_output_items,
779 )
780 }
781}
782
783pub fn parse_sse_lines(chunk: &str) -> Vec<(String, String)> {
786 let mut events = Vec::new();
787 let mut current_event = String::new();
788 let mut current_data = String::new();
789
790 for line in chunk.lines() {
791 if let Some(rest) = line.strip_prefix("event: ") {
792 current_event = rest.to_string();
793 } else if let Some(rest) = line.strip_prefix("data: ") {
794 current_data = rest.to_string();
795 } else if line.is_empty() && !current_data.is_empty() {
796 events.push((
797 if current_event.is_empty() {
798 "message".to_string()
799 } else {
800 current_event.clone()
801 },
802 current_data.clone(),
803 ));
804 current_event.clear();
805 current_data.clear();
806 }
807 }
808
809 if !current_data.is_empty() {
811 events.push((
812 if current_event.is_empty() {
813 "message".to_string()
814 } else {
815 current_event
816 },
817 current_data,
818 ));
819 }
820
821 events
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
836 fn accumulated_usage_and_stop_reason_survive_finish() {
837 let mut acc = StreamAccumulator::default();
838 acc.push(&StreamEvent::TextDelta("hello".into()));
839 acc.push(&StreamEvent::Usage {
840 input_tokens: 28,
841 output_tokens: 5,
842 cache_read_input_tokens: 0,
843 cache_creation_input_tokens: 0,
844 });
845 acc.push(&StreamEvent::StopReason("length".into()));
846
847 let (text, _tools, usage, stop) = acc.finish_with_usage();
848 assert_eq!(text, "hello");
849 let usage = usage.expect("a reported Usage event must not be dropped");
850 assert_eq!(usage.prompt_tokens, 28);
851 assert_eq!(usage.completion_tokens, 5);
852 assert_eq!(usage.total_tokens, 33);
853 assert_eq!(
854 stop.as_deref(),
855 Some("length"),
856 "the provider stop_reason feeds was_truncated and was being dropped too"
857 );
858 }
859
860 #[test]
863 fn absent_usage_stays_none_rather_than_zero() {
864 let mut acc = StreamAccumulator::default();
865 acc.push(&StreamEvent::TextDelta("hi".into()));
866 let (_text, _tools, usage, stop) = acc.finish_with_usage();
867 assert!(
868 usage.is_none(),
869 "no Usage event must yield None so callers can fall back to an estimator"
870 );
871 assert!(stop.is_none());
872 }
873
874 #[test]
882 fn managed_error_events_carry_type_and_code() {
883 let events = parse_openai_responses_sse_line(
884 "error",
885 r#"{"error":{"message":"content refused","type":"invalid_request_error","code":"content_policy_violation"}}"#,
886 );
887 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
888 panic!("expected StreamEvent::Error, got {:?}", events.first());
889 };
890 assert!(msg.contains("content refused"), "message dropped: {msg}");
891 assert!(
892 msg.contains("type=invalid_request_error"),
893 "type dropped: {msg}"
894 );
895 assert!(
896 msg.contains("code=content_policy_violation"),
897 "code dropped: {msg}"
898 );
899 }
900
901 #[test]
905 fn a_messageless_managed_error_still_reports_its_code() {
906 let events = parse_openai_responses_sse_line(
907 "response.failed",
908 r#"{"response":{"error":{"code":"content_filter"}}}"#,
909 );
910 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
911 panic!("expected StreamEvent::Error");
912 };
913 assert!(msg.contains("managed inference failed"), "{msg}");
914 assert!(
915 msg.contains("code=content_filter"),
916 "classification lost: {msg}"
917 );
918 }
919
920 #[test]
923 fn a_bare_managed_error_is_unchanged() {
924 let events = parse_openai_responses_sse_line("error", r#"{"error":{}}"#);
925 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
926 panic!("expected StreamEvent::Error");
927 };
928 assert_eq!(msg, "managed inference failed");
929 }
930
931 #[test]
932 fn parse_openai_text_delta() {
933 let line = r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#;
934 let events = parse_openai_sse_line(line);
935 assert_eq!(events.len(), 1);
936 match &events[0] {
937 StreamEvent::TextDelta(t) => assert_eq!(t, "Hello"),
938 other => panic!("expected TextDelta, got {:?}", other),
939 }
940 }
941
942 #[test]
943 fn parse_openai_tool_call_start() {
944 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"edit_file"}}]}}]}"#;
945 let events = parse_openai_sse_line(line);
946 assert_eq!(events.len(), 1);
947 match &events[0] {
948 StreamEvent::ToolCallStart { name, index, .. } => {
949 assert_eq!(name, "edit_file");
950 assert_eq!(*index, 0);
951 }
952 other => panic!("expected ToolCallStart, got {:?}", other),
953 }
954 }
955
956 #[test]
957 fn parse_openai_tool_call_delta() {
958 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}"#;
959 let events = parse_openai_sse_line(line);
960 assert_eq!(events.len(), 1);
961 match &events[0] {
962 StreamEvent::ToolCallDelta {
963 index,
964 arguments_delta,
965 } => {
966 assert_eq!(*index, 0);
967 assert!(arguments_delta.contains("path"));
968 }
969 other => panic!("expected ToolCallDelta, got {:?}", other),
970 }
971 }
972
973 #[test]
974 fn parse_openai_multiple_tool_calls_in_chunk() {
975 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file"}},{"index":1,"function":{"name":"search"}}]}}]}"#;
977 let events = parse_openai_sse_line(line);
978 assert_eq!(events.len(), 2);
979 match &events[0] {
980 StreamEvent::ToolCallStart { name, index, .. } => {
981 assert_eq!(name, "read_file");
982 assert_eq!(*index, 0);
983 }
984 other => panic!("expected ToolCallStart, got {:?}", other),
985 }
986 match &events[1] {
987 StreamEvent::ToolCallStart { name, index, .. } => {
988 assert_eq!(name, "search");
989 assert_eq!(*index, 1);
990 }
991 other => panic!("expected ToolCallStart, got {:?}", other),
992 }
993 }
994
995 #[test]
996 fn parse_openai_done() {
997 assert!(parse_openai_sse_line("data: [DONE]").is_empty());
998 }
999
1000 #[test]
1001 fn parse_anthropic_text_delta() {
1002 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}"#;
1003 let events = parse_anthropic_sse_line("content_block_delta", data);
1004 assert_eq!(events.len(), 1);
1005 match &events[0] {
1006 StreamEvent::TextDelta(t) => assert_eq!(t, "world"),
1007 other => panic!("expected TextDelta, got {:?}", other),
1008 }
1009 }
1010
1011 #[test]
1012 fn parse_anthropic_tool_start() {
1013 let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"search","input":{}}}"#;
1014 let events = parse_anthropic_sse_line("content_block_start", data);
1015 assert_eq!(events.len(), 1);
1016 match &events[0] {
1017 StreamEvent::ToolCallStart { name, index, .. } => {
1018 assert_eq!(name, "search");
1019 assert_eq!(*index, 1);
1020 }
1021 other => panic!("expected ToolCallStart, got {:?}", other),
1022 }
1023 }
1024
1025 #[test]
1026 fn accumulator_builds_result() {
1027 let mut acc = StreamAccumulator::default();
1028 acc.push(&StreamEvent::TextDelta("Hello ".into()));
1029 acc.push(&StreamEvent::TextDelta("world".into()));
1030 acc.push(&StreamEvent::ToolCallStart {
1031 name: "search".into(),
1032 index: 0,
1033 id: None,
1034 });
1035 acc.push(&StreamEvent::ToolCallDelta {
1036 index: 0,
1037 arguments_delta: r#"{"q":"test"}"#.into(),
1038 });
1039
1040 let (text, tools) = acc.finish();
1041 assert_eq!(text, "Hello world");
1042 assert_eq!(tools.len(), 1);
1043 assert_eq!(tools[0].name, "search");
1044 assert!(tools[0].arguments.contains_key("q"));
1045 }
1046
1047 #[test]
1048 fn parse_sse_lines_openai_format() {
1049 let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n";
1050 let events = parse_sse_lines(chunk);
1051 assert_eq!(events.len(), 2);
1052 assert_eq!(events[0].0, "message");
1053 assert_eq!(events[1].1, "[DONE]");
1054 }
1055
1056 #[test]
1057 fn parse_sse_lines_anthropic_format() {
1058 let chunk = "event: content_block_delta\ndata: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n";
1059 let events = parse_sse_lines(chunk);
1060 assert_eq!(events.len(), 1);
1061 assert_eq!(events[0].0, "content_block_delta");
1062 }
1063
1064 #[test]
1065 fn parse_anthropic_message_start_emits_usage() {
1066 let data = r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","usage":{"input_tokens":245,"output_tokens":1}}}"#;
1067 let events = parse_anthropic_sse_line("message_start", data);
1068 assert_eq!(events.len(), 1);
1069 match &events[0] {
1070 StreamEvent::Usage {
1071 input_tokens,
1072 output_tokens,
1073 ..
1074 } => {
1075 assert_eq!(*input_tokens, 245);
1076 assert_eq!(*output_tokens, 1);
1077 }
1078 other => panic!("expected Usage, got {:?}", other),
1079 }
1080 }
1081
1082 #[test]
1083 fn parse_anthropic_message_delta_emits_stop_reason_and_usage() {
1084 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1085 let events = parse_anthropic_sse_line("message_delta", data);
1086 assert_eq!(events.len(), 2);
1089 match &events[0] {
1090 StreamEvent::StopReason(reason) => assert_eq!(reason, "end_turn"),
1091 other => panic!("expected StopReason, got {:?}", other),
1092 }
1093 match &events[1] {
1094 StreamEvent::Usage {
1095 input_tokens,
1096 output_tokens,
1097 ..
1098 } => {
1099 assert_eq!(*input_tokens, 0);
1100 assert_eq!(*output_tokens, 87);
1101 }
1102 other => panic!("expected Usage, got {:?}", other),
1103 }
1104 }
1105
1106 #[test]
1107 fn parse_anthropic_message_delta_max_tokens_stop_reason() {
1108 let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1111 let events = parse_anthropic_sse_line("message_delta", data);
1112 assert!(matches!(
1113 &events[0],
1114 StreamEvent::StopReason(r) if r == "max_tokens"
1115 ));
1116 }
1117
1118 #[test]
1119 fn parse_openai_finish_reason_length_surfaces() {
1120 let line = r#"data: {"choices":[{"delta":{"content":""},"finish_reason":"length"}]}"#;
1123 let events = parse_openai_sse_line(line);
1124 assert!(events
1125 .iter()
1126 .any(|e| matches!(e, StreamEvent::StopReason(r) if r == "length")));
1127 }
1128
1129 #[test]
1130 fn accumulator_captures_stop_reason() {
1131 let mut acc = StreamAccumulator::default();
1132 acc.push(&StreamEvent::TextDelta("partial".into()));
1133 acc.push(&StreamEvent::StopReason("max_tokens".into()));
1134 let (_, _, _, stop) = acc.finish_with_usage();
1135 assert_eq!(stop.as_deref(), Some("max_tokens"));
1136 }
1137
1138 #[test]
1139 fn parse_anthropic_message_start_without_usage_is_empty() {
1140 let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1142 assert!(parse_anthropic_sse_line("message_start", data).is_empty());
1143 }
1144
1145 #[test]
1146 fn accumulator_tracks_usage_across_anthropic_stream() {
1147 let mut acc = StreamAccumulator::default();
1150 for event in parse_anthropic_sse_line(
1151 "message_start",
1152 r#"{"message":{"usage":{"input_tokens":245,"output_tokens":1}}}"#,
1153 ) {
1154 acc.push(&event);
1155 }
1156 for event in parse_anthropic_sse_line(
1157 "content_block_start",
1158 r#"{"index":0,"content_block":{"type":"text","text":""}}"#,
1159 ) {
1160 acc.push(&event);
1161 }
1162 for (chunk, _) in [
1163 (r#"{"delta":{"type":"text_delta","text":"Hello"}}"#, ()),
1164 (r#"{"delta":{"type":"text_delta","text":", "}}"#, ()),
1165 (r#"{"delta":{"type":"text_delta","text":"world"}}"#, ()),
1166 ] {
1167 for event in parse_anthropic_sse_line("content_block_delta", chunk) {
1168 acc.push(&event);
1169 }
1170 }
1171 for event in parse_anthropic_sse_line("message_delta", r#"{"usage":{"output_tokens":87}}"#)
1172 {
1173 acc.push(&event);
1174 }
1175
1176 let (text, tools, usage, _stop) = acc.finish_with_usage();
1177 assert_eq!(text, "Hello, world");
1178 assert!(tools.is_empty());
1179 let usage = usage.expect("provider reported usage; must surface");
1180 assert_eq!(usage.prompt_tokens, 245);
1181 assert_eq!(usage.completion_tokens, 87);
1183 assert_eq!(usage.total_tokens, 332);
1184 }
1185
1186 #[test]
1187 fn parse_openai_final_chunk_emits_usage() {
1188 let line = r#"data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87,"total_tokens":332}}"#;
1191 let events = parse_openai_sse_line(line);
1192 assert_eq!(events.len(), 1);
1193 match &events[0] {
1194 StreamEvent::Usage {
1195 input_tokens,
1196 output_tokens,
1197 ..
1198 } => {
1199 assert_eq!(*input_tokens, 245);
1200 assert_eq!(*output_tokens, 87);
1201 }
1202 other => panic!("expected Usage, got {:?}", other),
1203 }
1204 }
1205
1206 #[test]
1207 fn accumulator_tracks_usage_across_openai_stream() {
1208 let mut acc = StreamAccumulator::default();
1211 for line in [
1212 r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#,
1213 r#"data: {"choices":[{"delta":{"content":", "}}]}"#,
1214 r#"data: {"choices":[{"delta":{"content":"world"}}]}"#,
1215 r#"data: {"id":"chatcmpl-1","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87}}"#,
1216 ] {
1217 for event in parse_openai_sse_line(line) {
1218 acc.push(&event);
1219 }
1220 }
1221
1222 let (text, tools, usage, _stop) = acc.finish_with_usage();
1223 assert_eq!(text, "Hello, world");
1224 assert!(tools.is_empty());
1225 let usage = usage.expect("provider reported usage; must surface");
1226 assert_eq!(usage.prompt_tokens, 245);
1227 assert_eq!(usage.completion_tokens, 87);
1228 assert_eq!(usage.total_tokens, 332);
1229 }
1230
1231 #[test]
1232 fn accumulator_returns_no_usage_when_provider_silent() {
1233 let mut acc = StreamAccumulator::default();
1237 acc.push(&StreamEvent::TextDelta("hi".into()));
1238 let (_, _, usage, _stop) = acc.finish_with_usage();
1239 assert!(usage.is_none());
1240 }
1241
1242 #[test]
1243 fn anthropic_stream_decodes_cache_tokens_from_message_start() {
1244 let mut acc = StreamAccumulator::default();
1248 let start = r#"{"message":{"usage":{"input_tokens":50,"output_tokens":1,"cache_read_input_tokens":4000,"cache_creation_input_tokens":600}}}"#;
1249 for e in parse_anthropic_sse_line("message_start", start) {
1250 acc.push(&e);
1251 }
1252 let delta = r#"{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1253 for e in parse_anthropic_sse_line("message_delta", delta) {
1254 acc.push(&e);
1255 }
1256 let (_t, _c, usage, stop) = acc.finish_with_usage();
1257 let u = usage.expect("usage surfaced");
1258 assert_eq!(u.prompt_tokens, 50, "uncached prefix");
1259 assert_eq!(u.completion_tokens, 87, "final output from message_delta");
1260 assert_eq!(u.cache_read_input_tokens, 4000);
1261 assert_eq!(u.cache_creation_input_tokens, 600);
1262 assert_eq!(stop.as_deref(), Some("end_turn"));
1263 }
1264
1265 #[test]
1266 fn openai_stream_normalizes_cached_tokens_out_of_prompt() {
1267 let mut acc = StreamAccumulator::default();
1271 let chunk = r#"data: {"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":40,"prompt_tokens_details":{"cached_tokens":800}}}"#;
1272 for e in parse_openai_sse_line(chunk) {
1273 acc.push(&e);
1274 }
1275 let (_t, _c, usage, _s) = acc.finish_with_usage();
1276 let u = usage.expect("usage surfaced");
1277 assert_eq!(u.prompt_tokens, 200, "uncached = 1000 - 800");
1278 assert_eq!(u.cache_read_input_tokens, 800);
1279 assert_eq!(
1280 u.cache_creation_input_tokens, 0,
1281 "OpenAI has no write bucket"
1282 );
1283 }
1284
1285 #[test]
1286 fn responses_failure_is_a_terminal_safe_error_event() {
1287 let events = parse_openai_responses_sse_line(
1288 "response.failed",
1289 r#"{"response":{"error":{"message":"managed model unavailable","stack":"secret"}}}"#,
1290 );
1291 assert!(matches!(
1292 events.as_slice(),
1293 [StreamEvent::Error(message)] if message == "managed model unavailable"
1294 ));
1295 assert!(!format!("{events:?}").contains("secret"));
1296 }
1297
1298 #[test]
1299 fn responses_reasoning_item_done_is_retained_verbatim() {
1300 let data = r#"{"output_index":0,"item":{"type":"reasoning","id":"rs_1","status":"completed","summary":[{"type":"summary_text","text":"safe summary"}],"encrypted_content":"opaque-ciphertext"}}"#;
1301 let events = parse_openai_responses_sse_line("response.output_item.done", data);
1302 let expected = serde_json::json!({
1303 "type": "reasoning",
1304 "id": "rs_1",
1305 "status": "completed",
1306 "summary": [{"type": "summary_text", "text": "safe summary"}],
1307 "encrypted_content": "opaque-ciphertext",
1308 });
1309 assert!(matches!(
1310 events.as_slice(),
1311 [StreamEvent::ProviderOutputItem(item)] if item == &expected
1312 ));
1313 let mut accumulator = StreamAccumulator::default();
1314 accumulator.push(&events[0]);
1315 let (_, _, _, _, items) = accumulator.finish_with_provider_output_items();
1316 assert_eq!(items, vec![expected]);
1317 }
1318
1319 #[test]
1320 fn responses_incomplete_is_terminal_failure_not_success() {
1321 let events = parse_openai_responses_sse_line(
1322 "response.incomplete",
1323 r#"{"response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":17,"output_tokens":9}}}"#,
1324 );
1325 assert!(
1326 events
1327 .iter()
1328 .any(|event| matches!(event, StreamEvent::Error(_))),
1329 "response.incomplete must emit a terminal error"
1330 );
1331 }
1332}