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 ToolCallArguments {
36 index: usize,
37 arguments: String,
38 authoritative: bool,
39 },
40 Usage {
48 input_tokens: u64,
53 output_tokens: u64,
54 cache_read_input_tokens: u64,
58 cache_creation_input_tokens: u64,
62 },
63 StopReason(String),
69 ProviderOutputItem(serde_json::Value),
74 Error(String),
78 Attribution(crate::outcome::UnattributedReason),
84 Done {
86 text: String,
87 tool_calls: Vec<ToolCall>,
88 },
89}
90
91pub fn parse_openai_responses_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
99 let json: serde_json::Value = match serde_json::from_str(data) {
100 Ok(v) => v,
101 Err(_) => return Vec::new(),
102 };
103 let mut events = Vec::new();
104 match event_type {
105 "response.output_text.delta" => {
106 if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
107 if !d.is_empty() {
108 events.push(StreamEvent::TextDelta(d.to_string()));
109 }
110 }
111 }
112 "response.output_item.added" | "response.output_item.done" => {
113 if let Some(item) = json.get("item") {
114 if item.get("type").and_then(|t| t.as_str()) == Some("function_call") {
115 let name = item
116 .get("name")
117 .and_then(|n| n.as_str())
118 .unwrap_or("")
119 .to_string();
120 let id = item
121 .get("call_id")
122 .or_else(|| item.get("id"))
123 .and_then(|i| i.as_str())
124 .map(|s| s.to_string());
125 let index = json
126 .get("output_index")
127 .and_then(|v| v.as_u64())
128 .unwrap_or(0) as usize;
129 if !name.is_empty() && event_type == "response.output_item.added" {
133 events.push(StreamEvent::ToolCallStart { name, index, id });
134 }
135 if let Some(arguments) = item
136 .get("arguments")
137 .and_then(|value| value.as_str())
138 .filter(|arguments| !arguments.is_empty())
139 {
140 events.push(StreamEvent::ToolCallArguments {
141 index,
142 arguments: arguments.to_string(),
143 authoritative: event_type == "response.output_item.done",
144 });
145 }
146 } else if event_type == "response.output_item.done"
147 && item.get("type").and_then(|value| value.as_str()) == Some("reasoning")
148 {
149 events.push(StreamEvent::ProviderOutputItem(item.clone()));
150 }
151 }
152 }
153 "response.function_call_arguments.delta" => {
154 if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
155 let index = json
156 .get("output_index")
157 .and_then(|v| v.as_u64())
158 .unwrap_or(0) as usize;
159 events.push(StreamEvent::ToolCallDelta {
160 index,
161 arguments_delta: d.to_string(),
162 });
163 }
164 }
165 "response.function_call_arguments.done" => {
166 if let Some(arguments) = json.get("arguments").and_then(|value| value.as_str()) {
167 let index = json
168 .get("output_index")
169 .and_then(|value| value.as_u64())
170 .unwrap_or(0) as usize;
171 events.push(StreamEvent::ToolCallArguments {
172 index,
173 arguments: arguments.to_string(),
174 authoritative: true,
175 });
176 }
177 }
178 "response.completed" | "response.incomplete" => {
179 if let Some(resp) = json.get("response") {
180 if let Some(u) = resp.get("usage") {
181 let input_total = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
185 let cached = u
186 .get("input_tokens_details")
187 .and_then(|d| d.get("cached_tokens"))
188 .and_then(|v| v.as_u64())
189 .unwrap_or(0)
190 .min(input_total);
191 events.push(StreamEvent::Usage {
192 input_tokens: input_total - cached,
193 output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0),
194 cache_read_input_tokens: cached,
195 cache_creation_input_tokens: 0,
196 });
197 }
198 if event_type == "response.incomplete" {
199 let reason = resp
200 .pointer("/incomplete_details/reason")
201 .and_then(|r| r.as_str())
202 .unwrap_or("incomplete");
203 events.push(StreamEvent::StopReason(reason.to_string()));
204 events.push(StreamEvent::Error(format!(
205 "managed inference incomplete: {reason}"
206 )));
207 } else {
208 events.push(StreamEvent::Done {
213 text: String::new(),
214 tool_calls: Vec::new(),
215 });
216 }
217 }
218 }
219 "error" | "response.failed" => {
220 let pick = |field: &str| {
221 json.pointer(&format!("/error/{field}"))
222 .or_else(|| json.pointer(&format!("/response/error/{field}")))
223 .and_then(|value| value.as_str())
224 .map(str::trim)
225 .filter(|value| !value.is_empty())
226 .map(str::to_string)
227 };
228 let message = pick("message");
229 let kind = pick("type");
243 let code = pick("code");
244 let mut detail = message.unwrap_or_else(|| "managed inference failed".to_string());
245 let tags: Vec<String> = [("type", kind), ("code", code)]
246 .into_iter()
247 .filter_map(|(label, value)| value.map(|v| format!("{label}={v}")))
248 .collect();
249 if !tags.is_empty() {
250 detail.push_str(&format!(" ({})", tags.join(", ")));
251 }
252 events.push(StreamEvent::Error(detail));
256 }
257 _ => {}
258 }
259 events
260}
261
262pub fn error_tags(detail: &str) -> (Option<&str>, Option<&str>) {
274 let Some(open) = detail.rfind(" (") else {
275 return (None, None);
276 };
277 let Some(close) = detail[open..].rfind(')') else {
278 return (None, None);
279 };
280 let mut kind = None;
281 let mut code = None;
282 for part in detail[open + 2..open + close].split(", ") {
283 if let Some(v) = part.strip_prefix("type=") {
284 kind = Some(v);
285 } else if let Some(v) = part.strip_prefix("code=") {
286 code = Some(v);
287 }
288 }
289 (kind, code)
290}
291
292pub fn content_refusal_tags(detail: &str) -> Option<(Option<String>, Option<String>)> {
316 let (kind, code) = error_tags(detail);
317 let refused = |v: &str| {
318 let v = v.to_ascii_lowercase();
319 v.contains("content_policy")
320 || v.contains("content_filter")
321 || v.contains("moderation")
322 || v.contains("safety")
323 };
324 (code.is_some_and(refused) || kind.is_some_and(refused))
325 .then(|| (kind.map(str::to_string), code.map(str::to_string)))
326}
327
328pub fn parse_google_sse_line(data: &str) -> Vec<StreamEvent> {
336 let json: serde_json::Value = match serde_json::from_str(data) {
337 Ok(v) => v,
338 Err(_) => return Vec::new(),
339 };
340 let mut events = Vec::new();
341 if let Some(parts) = json
342 .pointer("/candidates/0/content/parts")
343 .and_then(|p| p.as_array())
344 {
345 for (i, part) in parts.iter().enumerate() {
346 if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
347 if !t.is_empty() {
348 events.push(StreamEvent::TextDelta(t.to_string()));
349 }
350 }
351 if let Some(fc) = part.get("functionCall") {
352 let name = fc
353 .get("name")
354 .and_then(|n| n.as_str())
355 .unwrap_or("")
356 .to_string();
357 if !name.is_empty() {
358 let args = fc
359 .get("args")
360 .map(|a| a.to_string())
361 .unwrap_or_else(|| "{}".to_string());
362 events.push(StreamEvent::ToolCallStart {
363 name,
364 index: i,
365 id: None,
366 });
367 events.push(StreamEvent::ToolCallDelta {
368 index: i,
369 arguments_delta: args,
370 });
371 }
372 }
373 }
374 }
375 if let Some(u) = json.get("usageMetadata") {
376 events.push(StreamEvent::Usage {
377 input_tokens: u
378 .get("promptTokenCount")
379 .and_then(|v| v.as_u64())
380 .unwrap_or(0),
381 output_tokens: u
382 .get("candidatesTokenCount")
383 .and_then(|v| v.as_u64())
384 .unwrap_or(0),
385 cache_read_input_tokens: 0,
387 cache_creation_input_tokens: 0,
388 });
389 }
390 if let Some(fr) = json
391 .pointer("/candidates/0/finishReason")
392 .and_then(|r| r.as_str())
393 {
394 events.push(StreamEvent::StopReason(fr.to_string()));
395 }
396 events
397}
398
399pub fn parse_openai_sse_line(line: &str) -> Vec<StreamEvent> {
402 let data = match line.strip_prefix("data: ") {
403 Some(d) => d,
404 None => return Vec::new(),
405 };
406 if data == "[DONE]" {
407 return Vec::new();
408 }
409
410 let json: serde_json::Value = match serde_json::from_str(data) {
411 Ok(v) => v,
412 Err(_) => return Vec::new(),
413 };
414
415 let mut events = Vec::new();
416
417 if let Some(reason) = json
421 .get("choices")
422 .and_then(|c| c.as_array())
423 .and_then(|c| c.first())
424 .and_then(|c| c.get("finish_reason"))
425 .and_then(|r| r.as_str())
426 {
427 if !reason.is_empty() {
428 events.push(StreamEvent::StopReason(reason.to_string()));
429 }
430 }
431
432 if let Some(delta) = json
435 .get("choices")
436 .and_then(|c| c.as_array())
437 .and_then(|c| c.first())
438 .and_then(|c| c.get("delta"))
439 {
440 if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
441 if !content.is_empty() {
442 events.push(StreamEvent::TextDelta(content.to_string()));
443 }
444 }
445
446 if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
448 for tc in tool_calls {
449 let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
450 if let Some(function) = tc.get("function") {
451 if let Some(name) = function.get("name").and_then(|n| n.as_str()) {
452 let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
453 events.push(StreamEvent::ToolCallStart {
454 name: name.to_string(),
455 index,
456 id,
457 });
458 }
459 if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) {
460 if !args.is_empty() {
461 events.push(StreamEvent::ToolCallDelta {
462 index,
463 arguments_delta: args.to_string(),
464 });
465 }
466 }
467 }
468 }
469 }
470 }
471
472 if let Some(usage) = json.get("usage") {
476 let input = usage
477 .get("prompt_tokens")
478 .and_then(|n| n.as_u64())
479 .unwrap_or(0);
480 let output = usage
481 .get("completion_tokens")
482 .and_then(|n| n.as_u64())
483 .unwrap_or(0);
484 let cached = usage
487 .get("prompt_tokens_details")
488 .and_then(|d| d.get("cached_tokens"))
489 .and_then(|n| n.as_u64())
490 .unwrap_or(0)
491 .min(input);
492 if input != 0 || output != 0 {
493 events.push(StreamEvent::Usage {
494 input_tokens: input - cached,
495 output_tokens: output,
496 cache_read_input_tokens: cached,
497 cache_creation_input_tokens: 0,
498 });
499 }
500 }
501
502 events
503}
504
505pub fn parse_anthropic_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
507 match event_type {
508 "content_block_delta" => {
509 let json: serde_json::Value = match serde_json::from_str(data) {
510 Ok(v) => v,
511 Err(_) => return Vec::new(),
512 };
513 let delta = match json.get("delta") {
514 Some(d) => d,
515 None => return Vec::new(),
516 };
517 let delta_type = match delta.get("type").and_then(|t| t.as_str()) {
518 Some(t) => t,
519 None => return Vec::new(),
520 };
521
522 match delta_type {
523 "text_delta" => match delta.get("text").and_then(|t| t.as_str()) {
524 Some(text) => vec![StreamEvent::TextDelta(text.to_string())],
525 None => Vec::new(),
526 },
527 "input_json_delta" => match delta.get("partial_json").and_then(|p| p.as_str()) {
528 Some(partial) => {
529 let index =
530 json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
531 vec![StreamEvent::ToolCallDelta {
532 index,
533 arguments_delta: partial.to_string(),
534 }]
535 }
536 None => Vec::new(),
537 },
538 _ => Vec::new(),
539 }
540 }
541 "content_block_start" => {
542 let json: serde_json::Value = match serde_json::from_str(data) {
543 Ok(v) => v,
544 Err(_) => return Vec::new(),
545 };
546 let block = match json.get("content_block") {
547 Some(b) => b,
548 None => return Vec::new(),
549 };
550 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
551 if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
552 let index = json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
553 let id = block
554 .get("id")
555 .and_then(|i| i.as_str())
556 .map(|s| s.to_string());
557 return vec![StreamEvent::ToolCallStart {
558 name: name.to_string(),
559 index,
560 id,
561 }];
562 }
563 }
564 Vec::new()
565 }
566 "message_start" => {
570 let json: serde_json::Value = match serde_json::from_str(data) {
571 Ok(v) => v,
572 Err(_) => return Vec::new(),
573 };
574 let Some(usage) = json.pointer("/message/usage") else {
575 return Vec::new();
576 };
577 let input = usage
578 .get("input_tokens")
579 .and_then(|n| n.as_u64())
580 .unwrap_or(0);
581 let output = usage
582 .get("output_tokens")
583 .and_then(|n| n.as_u64())
584 .unwrap_or(0);
585 let cache_read = usage
588 .get("cache_read_input_tokens")
589 .and_then(|n| n.as_u64())
590 .unwrap_or(0);
591 let cache_creation = usage
592 .get("cache_creation_input_tokens")
593 .and_then(|n| n.as_u64())
594 .unwrap_or(0);
595 if input == 0 && output == 0 && cache_read == 0 && cache_creation == 0 {
596 return Vec::new();
597 }
598 vec![StreamEvent::Usage {
599 input_tokens: input,
600 output_tokens: output,
601 cache_read_input_tokens: cache_read,
602 cache_creation_input_tokens: cache_creation,
603 }]
604 }
605 "message_delta" => {
609 let json: serde_json::Value = match serde_json::from_str(data) {
610 Ok(v) => v,
611 Err(_) => return Vec::new(),
612 };
613 let mut events = Vec::new();
614 if let Some(reason) = json.pointer("/delta/stop_reason").and_then(|r| r.as_str()) {
617 if !reason.is_empty() {
618 events.push(StreamEvent::StopReason(reason.to_string()));
619 }
620 }
621 if let Some(usage) = json.get("usage") {
622 let input = usage
623 .get("input_tokens")
624 .and_then(|n| n.as_u64())
625 .unwrap_or(0);
626 let output = usage
627 .get("output_tokens")
628 .and_then(|n| n.as_u64())
629 .unwrap_or(0);
630 if input != 0 || output != 0 {
631 events.push(StreamEvent::Usage {
632 input_tokens: input,
633 output_tokens: output,
634 cache_read_input_tokens: 0,
637 cache_creation_input_tokens: 0,
638 });
639 }
640 }
641 events
642 }
643 _ => Vec::new(),
644 }
645}
646
647#[derive(Default)]
649pub struct StreamAccumulator {
650 pub text: String,
651 tool_names: HashMap<usize, String>,
652 tool_args: HashMap<usize, String>,
653 tool_args_fallback: HashMap<usize, String>,
654 tool_args_authoritative: HashMap<usize, String>,
655 tool_ids: HashMap<usize, String>,
656 input_tokens: u64,
661 output_tokens: u64,
665 cache_read_input_tokens: u64,
667 cache_creation_input_tokens: u64,
669 saw_usage: bool,
674 stop_reason: Option<String>,
677 provider_output_items: Vec<serde_json::Value>,
679}
680
681impl StreamAccumulator {
682 pub fn push(&mut self, event: &StreamEvent) {
683 match event {
684 StreamEvent::TextDelta(t) => self.text.push_str(t),
685 StreamEvent::ToolCallStart { name, index, id } => {
686 self.tool_names.insert(*index, name.clone());
687 self.tool_args.entry(*index).or_default();
688 if let Some(id) = id {
689 self.tool_ids.insert(*index, id.clone());
690 }
691 }
692 StreamEvent::ToolCallDelta {
693 index,
694 arguments_delta,
695 } => {
696 self.tool_args
697 .entry(*index)
698 .or_default()
699 .push_str(arguments_delta);
700 }
701 StreamEvent::ToolCallArguments {
702 index,
703 arguments,
704 authoritative,
705 } => {
706 let destination = if *authoritative {
707 &mut self.tool_args_authoritative
708 } else {
709 &mut self.tool_args_fallback
710 };
711 destination.insert(*index, arguments.clone());
712 }
713 StreamEvent::Usage {
714 input_tokens,
715 output_tokens,
716 cache_read_input_tokens,
717 cache_creation_input_tokens,
718 } => {
719 self.saw_usage = true;
720 if *input_tokens > self.input_tokens {
727 self.input_tokens = *input_tokens;
728 }
729 if *output_tokens > self.output_tokens {
730 self.output_tokens = *output_tokens;
731 }
732 if *cache_read_input_tokens > self.cache_read_input_tokens {
733 self.cache_read_input_tokens = *cache_read_input_tokens;
734 }
735 if *cache_creation_input_tokens > self.cache_creation_input_tokens {
736 self.cache_creation_input_tokens = *cache_creation_input_tokens;
737 }
738 }
739 StreamEvent::StopReason(reason) => {
740 self.stop_reason = Some(reason.clone());
741 }
742 StreamEvent::ProviderOutputItem(item) => {
743 self.provider_output_items.push(item.clone());
744 }
745 StreamEvent::Error(_) | StreamEvent::Attribution(_) => {}
746 StreamEvent::Done { .. } => {}
747 }
748 }
749
750 pub fn finish(self) -> (String, Vec<ToolCall>) {
751 let (text, tool_calls, _, _) = self.finish_with_usage();
752 (text, tool_calls)
753 }
754
755 pub fn finish_with_usage(self) -> (String, Vec<ToolCall>, Option<TokenUsage>, Option<String>) {
763 let (text, tool_calls, usage, stop_reason, _) = self.finish_with_provider_output_items();
764 (text, tool_calls, usage, stop_reason)
765 }
766
767 pub fn finish_with_provider_output_items(
770 self,
771 ) -> (
772 String,
773 Vec<ToolCall>,
774 Option<TokenUsage>,
775 Option<String>,
776 Vec<serde_json::Value>,
777 ) {
778 let mut tool_calls = Vec::new();
779 let mut indices: Vec<usize> = self.tool_names.keys().copied().collect();
780 indices.sort();
781
782 for idx in indices {
783 let id = self.tool_ids.get(&idx).cloned();
784 let name = self.tool_names.get(&idx).cloned().unwrap_or_default();
785 let args_str = self
786 .tool_args_authoritative
787 .get(&idx)
788 .or_else(|| {
789 self.tool_args
790 .get(&idx)
791 .filter(|arguments| !arguments.is_empty())
792 })
793 .or_else(|| self.tool_args_fallback.get(&idx))
794 .cloned()
795 .unwrap_or_default();
796 let arguments: HashMap<String, serde_json::Value> = if args_str.trim().is_empty() {
797 HashMap::new()
798 } else {
799 match serde_json::from_str(&args_str) {
800 Ok(arguments) => arguments,
801 Err(error) => {
802 tracing::warn!(
803 tool = %name,
804 output_index = idx,
805 %error,
806 "tool-call arguments were not valid JSON; returning an empty argument map"
807 );
808 HashMap::new()
809 }
810 }
811 };
812 tool_calls.push(ToolCall {
813 id,
814 name,
815 arguments,
816 });
817 }
818
819 let usage = if self.saw_usage {
820 Some(TokenUsage {
821 prompt_tokens: self.input_tokens,
822 completion_tokens: self.output_tokens,
823 total_tokens: self.input_tokens + self.output_tokens,
824 context_window: 0,
828 cache_read_input_tokens: self.cache_read_input_tokens,
831 cache_creation_input_tokens: self.cache_creation_input_tokens,
832 })
833 } else {
834 None
835 };
836
837 let (text, tag_calls) = crate::tasks::generate::parse_tool_calls(&self.text);
843 let (text, tool_calls) = if tool_calls.is_empty() && !tag_calls.is_empty() {
844 (text, tag_calls)
845 } else {
846 (text, tool_calls)
848 };
849
850 (
851 text,
852 tool_calls,
853 usage,
854 self.stop_reason,
855 self.provider_output_items,
856 )
857 }
858}
859
860pub fn parse_sse_lines(chunk: &str) -> Vec<(String, String)> {
863 let mut events = Vec::new();
864 let mut current_event = String::new();
865 let mut current_data = String::new();
866
867 for line in chunk.lines() {
868 if let Some(rest) = line.strip_prefix("event: ") {
869 current_event = rest.to_string();
870 } else if let Some(rest) = line.strip_prefix("data: ") {
871 current_data = rest.to_string();
872 } else if line.is_empty() && !current_data.is_empty() {
873 events.push((
874 if current_event.is_empty() {
875 "message".to_string()
876 } else {
877 current_event.clone()
878 },
879 current_data.clone(),
880 ));
881 current_event.clear();
882 current_data.clear();
883 }
884 }
885
886 if !current_data.is_empty() {
888 events.push((
889 if current_event.is_empty() {
890 "message".to_string()
891 } else {
892 current_event
893 },
894 current_data,
895 ));
896 }
897
898 events
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 #[test]
913 fn accumulated_usage_and_stop_reason_survive_finish() {
914 let mut acc = StreamAccumulator::default();
915 acc.push(&StreamEvent::TextDelta("hello".into()));
916 acc.push(&StreamEvent::Usage {
917 input_tokens: 28,
918 output_tokens: 5,
919 cache_read_input_tokens: 0,
920 cache_creation_input_tokens: 0,
921 });
922 acc.push(&StreamEvent::StopReason("length".into()));
923
924 let (text, _tools, usage, stop) = acc.finish_with_usage();
925 assert_eq!(text, "hello");
926 let usage = usage.expect("a reported Usage event must not be dropped");
927 assert_eq!(usage.prompt_tokens, 28);
928 assert_eq!(usage.completion_tokens, 5);
929 assert_eq!(usage.total_tokens, 33);
930 assert_eq!(
931 stop.as_deref(),
932 Some("length"),
933 "the provider stop_reason feeds was_truncated and was being dropped too"
934 );
935 }
936
937 #[test]
940 fn absent_usage_stays_none_rather_than_zero() {
941 let mut acc = StreamAccumulator::default();
942 acc.push(&StreamEvent::TextDelta("hi".into()));
943 let (_text, _tools, usage, stop) = acc.finish_with_usage();
944 assert!(
945 usage.is_none(),
946 "no Usage event must yield None so callers can fall back to an estimator"
947 );
948 assert!(stop.is_none());
949 }
950
951 #[test]
959 fn managed_error_events_carry_type_and_code() {
960 let events = parse_openai_responses_sse_line(
961 "error",
962 r#"{"error":{"message":"content refused","type":"invalid_request_error","code":"content_policy_violation"}}"#,
963 );
964 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
965 panic!("expected StreamEvent::Error, got {:?}", events.first());
966 };
967 assert!(msg.contains("content refused"), "message dropped: {msg}");
968 assert!(
969 msg.contains("type=invalid_request_error"),
970 "type dropped: {msg}"
971 );
972 assert!(
973 msg.contains("code=content_policy_violation"),
974 "code dropped: {msg}"
975 );
976 }
977
978 #[test]
982 fn a_messageless_managed_error_still_reports_its_code() {
983 let events = parse_openai_responses_sse_line(
984 "response.failed",
985 r#"{"response":{"error":{"code":"content_filter"}}}"#,
986 );
987 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
988 panic!("expected StreamEvent::Error");
989 };
990 assert!(msg.contains("managed inference failed"), "{msg}");
991 assert!(
992 msg.contains("code=content_filter"),
993 "classification lost: {msg}"
994 );
995 }
996
997 #[test]
1000 fn a_bare_managed_error_is_unchanged() {
1001 let events = parse_openai_responses_sse_line("error", r#"{"error":{}}"#);
1002 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
1003 panic!("expected StreamEvent::Error");
1004 };
1005 assert_eq!(msg, "managed inference failed");
1006 }
1007
1008 #[test]
1009 fn parse_openai_text_delta() {
1010 let line = r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#;
1011 let events = parse_openai_sse_line(line);
1012 assert_eq!(events.len(), 1);
1013 match &events[0] {
1014 StreamEvent::TextDelta(t) => assert_eq!(t, "Hello"),
1015 other => panic!("expected TextDelta, got {:?}", other),
1016 }
1017 }
1018
1019 #[test]
1020 fn parse_openai_tool_call_start() {
1021 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"edit_file"}}]}}]}"#;
1022 let events = parse_openai_sse_line(line);
1023 assert_eq!(events.len(), 1);
1024 match &events[0] {
1025 StreamEvent::ToolCallStart { name, index, .. } => {
1026 assert_eq!(name, "edit_file");
1027 assert_eq!(*index, 0);
1028 }
1029 other => panic!("expected ToolCallStart, got {:?}", other),
1030 }
1031 }
1032
1033 #[test]
1034 fn parse_openai_tool_call_delta() {
1035 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}"#;
1036 let events = parse_openai_sse_line(line);
1037 assert_eq!(events.len(), 1);
1038 match &events[0] {
1039 StreamEvent::ToolCallDelta {
1040 index,
1041 arguments_delta,
1042 } => {
1043 assert_eq!(*index, 0);
1044 assert!(arguments_delta.contains("path"));
1045 }
1046 other => panic!("expected ToolCallDelta, got {:?}", other),
1047 }
1048 }
1049
1050 #[test]
1051 fn parse_openai_multiple_tool_calls_in_chunk() {
1052 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file"}},{"index":1,"function":{"name":"search"}}]}}]}"#;
1054 let events = parse_openai_sse_line(line);
1055 assert_eq!(events.len(), 2);
1056 match &events[0] {
1057 StreamEvent::ToolCallStart { name, index, .. } => {
1058 assert_eq!(name, "read_file");
1059 assert_eq!(*index, 0);
1060 }
1061 other => panic!("expected ToolCallStart, got {:?}", other),
1062 }
1063 match &events[1] {
1064 StreamEvent::ToolCallStart { name, index, .. } => {
1065 assert_eq!(name, "search");
1066 assert_eq!(*index, 1);
1067 }
1068 other => panic!("expected ToolCallStart, got {:?}", other),
1069 }
1070 }
1071
1072 #[test]
1073 fn parse_openai_done() {
1074 assert!(parse_openai_sse_line("data: [DONE]").is_empty());
1075 }
1076
1077 #[test]
1078 fn parse_anthropic_text_delta() {
1079 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}"#;
1080 let events = parse_anthropic_sse_line("content_block_delta", data);
1081 assert_eq!(events.len(), 1);
1082 match &events[0] {
1083 StreamEvent::TextDelta(t) => assert_eq!(t, "world"),
1084 other => panic!("expected TextDelta, got {:?}", other),
1085 }
1086 }
1087
1088 #[test]
1089 fn parse_anthropic_tool_start() {
1090 let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"search","input":{}}}"#;
1091 let events = parse_anthropic_sse_line("content_block_start", data);
1092 assert_eq!(events.len(), 1);
1093 match &events[0] {
1094 StreamEvent::ToolCallStart { name, index, .. } => {
1095 assert_eq!(name, "search");
1096 assert_eq!(*index, 1);
1097 }
1098 other => panic!("expected ToolCallStart, got {:?}", other),
1099 }
1100 }
1101
1102 #[test]
1103 fn accumulator_builds_result() {
1104 let mut acc = StreamAccumulator::default();
1105 acc.push(&StreamEvent::TextDelta("Hello ".into()));
1106 acc.push(&StreamEvent::TextDelta("world".into()));
1107 acc.push(&StreamEvent::ToolCallStart {
1108 name: "search".into(),
1109 index: 0,
1110 id: None,
1111 });
1112 acc.push(&StreamEvent::ToolCallDelta {
1113 index: 0,
1114 arguments_delta: r#"{"q":"test"}"#.into(),
1115 });
1116
1117 let (text, tools) = acc.finish();
1118 assert_eq!(text, "Hello world");
1119 assert_eq!(tools.len(), 1);
1120 assert_eq!(tools[0].name, "search");
1121 assert!(tools[0].arguments.contains_key("q"));
1122 }
1123
1124 #[test]
1125 fn parse_sse_lines_openai_format() {
1126 let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n";
1127 let events = parse_sse_lines(chunk);
1128 assert_eq!(events.len(), 2);
1129 assert_eq!(events[0].0, "message");
1130 assert_eq!(events[1].1, "[DONE]");
1131 }
1132
1133 #[test]
1134 fn parse_sse_lines_anthropic_format() {
1135 let chunk = "event: content_block_delta\ndata: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n";
1136 let events = parse_sse_lines(chunk);
1137 assert_eq!(events.len(), 1);
1138 assert_eq!(events[0].0, "content_block_delta");
1139 }
1140
1141 #[test]
1142 fn parse_anthropic_message_start_emits_usage() {
1143 let data = r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","usage":{"input_tokens":245,"output_tokens":1}}}"#;
1144 let events = parse_anthropic_sse_line("message_start", data);
1145 assert_eq!(events.len(), 1);
1146 match &events[0] {
1147 StreamEvent::Usage {
1148 input_tokens,
1149 output_tokens,
1150 ..
1151 } => {
1152 assert_eq!(*input_tokens, 245);
1153 assert_eq!(*output_tokens, 1);
1154 }
1155 other => panic!("expected Usage, got {:?}", other),
1156 }
1157 }
1158
1159 #[test]
1160 fn parse_anthropic_message_delta_emits_stop_reason_and_usage() {
1161 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1162 let events = parse_anthropic_sse_line("message_delta", data);
1163 assert_eq!(events.len(), 2);
1166 match &events[0] {
1167 StreamEvent::StopReason(reason) => assert_eq!(reason, "end_turn"),
1168 other => panic!("expected StopReason, got {:?}", other),
1169 }
1170 match &events[1] {
1171 StreamEvent::Usage {
1172 input_tokens,
1173 output_tokens,
1174 ..
1175 } => {
1176 assert_eq!(*input_tokens, 0);
1177 assert_eq!(*output_tokens, 87);
1178 }
1179 other => panic!("expected Usage, got {:?}", other),
1180 }
1181 }
1182
1183 #[test]
1184 fn parse_anthropic_message_delta_max_tokens_stop_reason() {
1185 let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1188 let events = parse_anthropic_sse_line("message_delta", data);
1189 assert!(matches!(
1190 &events[0],
1191 StreamEvent::StopReason(r) if r == "max_tokens"
1192 ));
1193 }
1194
1195 #[test]
1196 fn parse_openai_finish_reason_length_surfaces() {
1197 let line = r#"data: {"choices":[{"delta":{"content":""},"finish_reason":"length"}]}"#;
1200 let events = parse_openai_sse_line(line);
1201 assert!(events
1202 .iter()
1203 .any(|e| matches!(e, StreamEvent::StopReason(r) if r == "length")));
1204 }
1205
1206 #[test]
1207 fn accumulator_captures_stop_reason() {
1208 let mut acc = StreamAccumulator::default();
1209 acc.push(&StreamEvent::TextDelta("partial".into()));
1210 acc.push(&StreamEvent::StopReason("max_tokens".into()));
1211 let (_, _, _, stop) = acc.finish_with_usage();
1212 assert_eq!(stop.as_deref(), Some("max_tokens"));
1213 }
1214
1215 #[test]
1216 fn parse_anthropic_message_start_without_usage_is_empty() {
1217 let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1219 assert!(parse_anthropic_sse_line("message_start", data).is_empty());
1220 }
1221
1222 #[test]
1223 fn accumulator_tracks_usage_across_anthropic_stream() {
1224 let mut acc = StreamAccumulator::default();
1227 for event in parse_anthropic_sse_line(
1228 "message_start",
1229 r#"{"message":{"usage":{"input_tokens":245,"output_tokens":1}}}"#,
1230 ) {
1231 acc.push(&event);
1232 }
1233 for event in parse_anthropic_sse_line(
1234 "content_block_start",
1235 r#"{"index":0,"content_block":{"type":"text","text":""}}"#,
1236 ) {
1237 acc.push(&event);
1238 }
1239 for (chunk, _) in [
1240 (r#"{"delta":{"type":"text_delta","text":"Hello"}}"#, ()),
1241 (r#"{"delta":{"type":"text_delta","text":", "}}"#, ()),
1242 (r#"{"delta":{"type":"text_delta","text":"world"}}"#, ()),
1243 ] {
1244 for event in parse_anthropic_sse_line("content_block_delta", chunk) {
1245 acc.push(&event);
1246 }
1247 }
1248 for event in parse_anthropic_sse_line("message_delta", r#"{"usage":{"output_tokens":87}}"#)
1249 {
1250 acc.push(&event);
1251 }
1252
1253 let (text, tools, usage, _stop) = acc.finish_with_usage();
1254 assert_eq!(text, "Hello, world");
1255 assert!(tools.is_empty());
1256 let usage = usage.expect("provider reported usage; must surface");
1257 assert_eq!(usage.prompt_tokens, 245);
1258 assert_eq!(usage.completion_tokens, 87);
1260 assert_eq!(usage.total_tokens, 332);
1261 }
1262
1263 #[test]
1264 fn parse_openai_final_chunk_emits_usage() {
1265 let line = r#"data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87,"total_tokens":332}}"#;
1268 let events = parse_openai_sse_line(line);
1269 assert_eq!(events.len(), 1);
1270 match &events[0] {
1271 StreamEvent::Usage {
1272 input_tokens,
1273 output_tokens,
1274 ..
1275 } => {
1276 assert_eq!(*input_tokens, 245);
1277 assert_eq!(*output_tokens, 87);
1278 }
1279 other => panic!("expected Usage, got {:?}", other),
1280 }
1281 }
1282
1283 #[test]
1284 fn accumulator_tracks_usage_across_openai_stream() {
1285 let mut acc = StreamAccumulator::default();
1288 for line in [
1289 r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#,
1290 r#"data: {"choices":[{"delta":{"content":", "}}]}"#,
1291 r#"data: {"choices":[{"delta":{"content":"world"}}]}"#,
1292 r#"data: {"id":"chatcmpl-1","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87}}"#,
1293 ] {
1294 for event in parse_openai_sse_line(line) {
1295 acc.push(&event);
1296 }
1297 }
1298
1299 let (text, tools, usage, _stop) = acc.finish_with_usage();
1300 assert_eq!(text, "Hello, world");
1301 assert!(tools.is_empty());
1302 let usage = usage.expect("provider reported usage; must surface");
1303 assert_eq!(usage.prompt_tokens, 245);
1304 assert_eq!(usage.completion_tokens, 87);
1305 assert_eq!(usage.total_tokens, 332);
1306 }
1307
1308 #[test]
1309 fn accumulator_returns_no_usage_when_provider_silent() {
1310 let mut acc = StreamAccumulator::default();
1314 acc.push(&StreamEvent::TextDelta("hi".into()));
1315 let (_, _, usage, _stop) = acc.finish_with_usage();
1316 assert!(usage.is_none());
1317 }
1318
1319 #[test]
1320 fn anthropic_stream_decodes_cache_tokens_from_message_start() {
1321 let mut acc = StreamAccumulator::default();
1325 let start = r#"{"message":{"usage":{"input_tokens":50,"output_tokens":1,"cache_read_input_tokens":4000,"cache_creation_input_tokens":600}}}"#;
1326 for e in parse_anthropic_sse_line("message_start", start) {
1327 acc.push(&e);
1328 }
1329 let delta = r#"{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1330 for e in parse_anthropic_sse_line("message_delta", delta) {
1331 acc.push(&e);
1332 }
1333 let (_t, _c, usage, stop) = acc.finish_with_usage();
1334 let u = usage.expect("usage surfaced");
1335 assert_eq!(u.prompt_tokens, 50, "uncached prefix");
1336 assert_eq!(u.completion_tokens, 87, "final output from message_delta");
1337 assert_eq!(u.cache_read_input_tokens, 4000);
1338 assert_eq!(u.cache_creation_input_tokens, 600);
1339 assert_eq!(stop.as_deref(), Some("end_turn"));
1340 }
1341
1342 #[test]
1343 fn openai_stream_normalizes_cached_tokens_out_of_prompt() {
1344 let mut acc = StreamAccumulator::default();
1348 let chunk = r#"data: {"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":40,"prompt_tokens_details":{"cached_tokens":800}}}"#;
1349 for e in parse_openai_sse_line(chunk) {
1350 acc.push(&e);
1351 }
1352 let (_t, _c, usage, _s) = acc.finish_with_usage();
1353 let u = usage.expect("usage surfaced");
1354 assert_eq!(u.prompt_tokens, 200, "uncached = 1000 - 800");
1355 assert_eq!(u.cache_read_input_tokens, 800);
1356 assert_eq!(
1357 u.cache_creation_input_tokens, 0,
1358 "OpenAI has no write bucket"
1359 );
1360 }
1361
1362 #[test]
1363 fn responses_failure_is_a_terminal_safe_error_event() {
1364 let events = parse_openai_responses_sse_line(
1365 "response.failed",
1366 r#"{"response":{"error":{"message":"managed model unavailable","stack":"secret"}}}"#,
1367 );
1368 assert!(matches!(
1369 events.as_slice(),
1370 [StreamEvent::Error(message)] if message == "managed model unavailable"
1371 ));
1372 assert!(!format!("{events:?}").contains("secret"));
1373 }
1374
1375 #[test]
1376 fn responses_reasoning_item_done_is_retained_verbatim() {
1377 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"}}"#;
1378 let events = parse_openai_responses_sse_line("response.output_item.done", data);
1379 let expected = serde_json::json!({
1380 "type": "reasoning",
1381 "id": "rs_1",
1382 "status": "completed",
1383 "summary": [{"type": "summary_text", "text": "safe summary"}],
1384 "encrypted_content": "opaque-ciphertext",
1385 });
1386 assert!(matches!(
1387 events.as_slice(),
1388 [StreamEvent::ProviderOutputItem(item)] if item == &expected
1389 ));
1390 let mut accumulator = StreamAccumulator::default();
1391 accumulator.push(&events[0]);
1392 let (_, _, _, _, items) = accumulator.finish_with_provider_output_items();
1393 assert_eq!(items, vec![expected]);
1394 }
1395
1396 #[test]
1397 fn responses_incomplete_is_terminal_failure_not_success() {
1398 let events = parse_openai_responses_sse_line(
1399 "response.incomplete",
1400 r#"{"response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":17,"output_tokens":9}}}"#,
1401 );
1402 assert!(
1403 events
1404 .iter()
1405 .any(|event| matches!(event, StreamEvent::Error(_))),
1406 "response.incomplete must emit a terminal error"
1407 );
1408 }
1409}