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 Substituted(StreamSubstitution),
93 Done {
95 text: String,
96 tool_calls: Vec<ToolCall>,
97 },
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102pub struct StreamSubstitution {
103 pub model_id: String,
105 pub substituted_from: Option<String>,
108 pub substitution_reason: Option<crate::FallbackReason>,
110}
111
112impl StreamSubstitution {
113 pub fn annotate(&self, frame: &mut serde_json::Value) {
118 frame["local_last_resort"] = serde_json::json!(true);
119 if let Some(from) = &self.substituted_from {
120 frame["substituted_from"] = serde_json::json!(from);
121 }
122 if let Some(reason) = self.substitution_reason {
123 frame["substitution_reason"] = serde_json::json!(reason);
124 }
125 }
126}
127
128pub fn parse_openai_responses_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
136 let json: serde_json::Value = match serde_json::from_str(data) {
137 Ok(v) => v,
138 Err(_) => return Vec::new(),
139 };
140 let mut events = Vec::new();
141 match event_type {
142 "response.output_text.delta" => {
143 if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
144 if !d.is_empty() {
145 events.push(StreamEvent::TextDelta(d.to_string()));
146 }
147 }
148 }
149 "response.output_item.added" | "response.output_item.done" => {
150 if let Some(item) = json.get("item") {
151 if item.get("type").and_then(|t| t.as_str()) == Some("function_call") {
152 let name = item
153 .get("name")
154 .and_then(|n| n.as_str())
155 .unwrap_or("")
156 .to_string();
157 let id = item
158 .get("call_id")
159 .or_else(|| item.get("id"))
160 .and_then(|i| i.as_str())
161 .map(|s| s.to_string());
162 let index = json
163 .get("output_index")
164 .and_then(|v| v.as_u64())
165 .unwrap_or(0) as usize;
166 if !name.is_empty() && event_type == "response.output_item.added" {
170 events.push(StreamEvent::ToolCallStart { name, index, id });
171 }
172 if let Some(arguments) = item
173 .get("arguments")
174 .and_then(|value| value.as_str())
175 .filter(|arguments| !arguments.is_empty())
176 {
177 events.push(StreamEvent::ToolCallArguments {
178 index,
179 arguments: arguments.to_string(),
180 authoritative: event_type == "response.output_item.done",
181 });
182 }
183 } else if event_type == "response.output_item.done"
184 && item.get("type").and_then(|value| value.as_str()) == Some("reasoning")
185 {
186 events.push(StreamEvent::ProviderOutputItem(item.clone()));
187 }
188 }
189 }
190 "response.function_call_arguments.delta" => {
191 if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
192 let index = json
193 .get("output_index")
194 .and_then(|v| v.as_u64())
195 .unwrap_or(0) as usize;
196 events.push(StreamEvent::ToolCallDelta {
197 index,
198 arguments_delta: d.to_string(),
199 });
200 }
201 }
202 "response.function_call_arguments.done" => {
203 if let Some(arguments) = json.get("arguments").and_then(|value| value.as_str()) {
204 let index = json
205 .get("output_index")
206 .and_then(|value| value.as_u64())
207 .unwrap_or(0) as usize;
208 events.push(StreamEvent::ToolCallArguments {
209 index,
210 arguments: arguments.to_string(),
211 authoritative: true,
212 });
213 }
214 }
215 "response.completed" | "response.incomplete" => {
216 if let Some(resp) = json.get("response") {
217 if let Some(u) = resp.get("usage") {
218 let input_total = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
222 let cached = u
223 .get("input_tokens_details")
224 .and_then(|d| d.get("cached_tokens"))
225 .and_then(|v| v.as_u64())
226 .unwrap_or(0)
227 .min(input_total);
228 events.push(StreamEvent::Usage {
229 input_tokens: input_total - cached,
230 output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0),
231 cache_read_input_tokens: cached,
232 cache_creation_input_tokens: 0,
233 });
234 }
235 if event_type == "response.incomplete" {
236 let reason = resp
237 .pointer("/incomplete_details/reason")
238 .and_then(|r| r.as_str())
239 .unwrap_or("incomplete");
240 events.push(StreamEvent::StopReason(reason.to_string()));
241 events.push(StreamEvent::Error(format!(
242 "managed inference incomplete: {reason}"
243 )));
244 } else {
245 events.push(StreamEvent::Done {
250 text: String::new(),
251 tool_calls: Vec::new(),
252 });
253 }
254 }
255 }
256 "error" | "response.failed" => {
257 let pick = |field: &str| {
258 json.pointer(&format!("/error/{field}"))
259 .or_else(|| json.pointer(&format!("/response/error/{field}")))
260 .and_then(|value| value.as_str())
261 .map(str::trim)
262 .filter(|value| !value.is_empty())
263 .map(str::to_string)
264 };
265 let message = pick("message");
266 let kind = pick("type");
280 let code = pick("code");
281 let mut detail = message.unwrap_or_else(|| "managed inference failed".to_string());
282 let tags: Vec<String> = [("type", kind), ("code", code)]
283 .into_iter()
284 .filter_map(|(label, value)| value.map(|v| format!("{label}={v}")))
285 .collect();
286 if !tags.is_empty() {
287 detail.push_str(&format!(" ({})", tags.join(", ")));
288 }
289 events.push(StreamEvent::Error(detail));
293 }
294 _ => {}
295 }
296 events
297}
298
299pub fn error_tags(detail: &str) -> (Option<&str>, Option<&str>) {
311 let Some(open) = detail.rfind(" (") else {
312 return (None, None);
313 };
314 let Some(close) = detail[open..].rfind(')') else {
315 return (None, None);
316 };
317 let mut kind = None;
318 let mut code = None;
319 for part in detail[open + 2..open + close].split(", ") {
320 if let Some(v) = part.strip_prefix("type=") {
321 kind = Some(v);
322 } else if let Some(v) = part.strip_prefix("code=") {
323 code = Some(v);
324 }
325 }
326 (kind, code)
327}
328
329pub fn content_refusal_tags(detail: &str) -> Option<(Option<String>, Option<String>)> {
353 let (kind, code) = error_tags(detail);
354 let refused = |v: &str| {
355 let v = v.to_ascii_lowercase();
356 v.contains("content_policy")
357 || v.contains("content_filter")
358 || v.contains("moderation")
359 || v.contains("safety")
360 };
361 (code.is_some_and(refused) || kind.is_some_and(refused))
362 .then(|| (kind.map(str::to_string), code.map(str::to_string)))
363}
364
365pub(crate) fn google_output_tokens(usage: &serde_json::Value) -> u64 {
374 let count = |field: &str| usage.get(field).and_then(|v| v.as_u64()).unwrap_or(0);
375 count("candidatesTokenCount") + count("thoughtsTokenCount")
376}
377
378pub fn parse_google_sse_line(data: &str) -> Vec<StreamEvent> {
386 let json: serde_json::Value = match serde_json::from_str(data) {
387 Ok(v) => v,
388 Err(_) => return Vec::new(),
389 };
390 let mut events = Vec::new();
391 if let Some(parts) = json
392 .pointer("/candidates/0/content/parts")
393 .and_then(|p| p.as_array())
394 {
395 for (i, part) in parts.iter().enumerate() {
396 if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
397 if !t.is_empty() {
398 events.push(StreamEvent::TextDelta(t.to_string()));
399 }
400 }
401 if let Some(fc) = part.get("functionCall") {
402 let name = fc
403 .get("name")
404 .and_then(|n| n.as_str())
405 .unwrap_or("")
406 .to_string();
407 if !name.is_empty() {
408 let args = fc
409 .get("args")
410 .map(|a| a.to_string())
411 .unwrap_or_else(|| "{}".to_string());
412 events.push(StreamEvent::ToolCallStart {
413 name,
414 index: i,
415 id: None,
416 });
417 events.push(StreamEvent::ToolCallDelta {
418 index: i,
419 arguments_delta: args,
420 });
421 }
422 }
423 }
424 }
425 if let Some(u) = json.get("usageMetadata") {
426 events.push(StreamEvent::Usage {
427 input_tokens: u
428 .get("promptTokenCount")
429 .and_then(|v| v.as_u64())
430 .unwrap_or(0),
431 output_tokens: google_output_tokens(u),
432 cache_read_input_tokens: 0,
434 cache_creation_input_tokens: 0,
435 });
436 }
437 if let Some(fr) = json
438 .pointer("/candidates/0/finishReason")
439 .and_then(|r| r.as_str())
440 {
441 events.push(StreamEvent::StopReason(fr.to_string()));
442 }
443 events
444}
445
446pub fn parse_openai_sse_line(line: &str) -> Vec<StreamEvent> {
449 let data = match line.strip_prefix("data: ") {
450 Some(d) => d,
451 None => return Vec::new(),
452 };
453 if data == "[DONE]" {
454 return Vec::new();
455 }
456
457 let json: serde_json::Value = match serde_json::from_str(data) {
458 Ok(v) => v,
459 Err(_) => return Vec::new(),
460 };
461
462 let mut events = Vec::new();
463
464 if let Some(reason) = json
468 .get("choices")
469 .and_then(|c| c.as_array())
470 .and_then(|c| c.first())
471 .and_then(|c| c.get("finish_reason"))
472 .and_then(|r| r.as_str())
473 {
474 if !reason.is_empty() {
475 events.push(StreamEvent::StopReason(reason.to_string()));
476 }
477 }
478
479 if let Some(delta) = json
482 .get("choices")
483 .and_then(|c| c.as_array())
484 .and_then(|c| c.first())
485 .and_then(|c| c.get("delta"))
486 {
487 if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
488 if !content.is_empty() {
489 events.push(StreamEvent::TextDelta(content.to_string()));
490 }
491 }
492
493 if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
495 for tc in tool_calls {
496 let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
497 if let Some(function) = tc.get("function") {
498 if let Some(name) = function.get("name").and_then(|n| n.as_str()) {
499 let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
500 events.push(StreamEvent::ToolCallStart {
501 name: name.to_string(),
502 index,
503 id,
504 });
505 }
506 if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) {
507 if !args.is_empty() {
508 events.push(StreamEvent::ToolCallDelta {
509 index,
510 arguments_delta: args.to_string(),
511 });
512 }
513 }
514 }
515 }
516 }
517 }
518
519 if let Some(usage) = json.get("usage") {
523 let input = usage
524 .get("prompt_tokens")
525 .and_then(|n| n.as_u64())
526 .unwrap_or(0);
527 let output = usage
528 .get("completion_tokens")
529 .and_then(|n| n.as_u64())
530 .unwrap_or(0);
531 let cached = usage
534 .get("prompt_tokens_details")
535 .and_then(|d| d.get("cached_tokens"))
536 .and_then(|n| n.as_u64())
537 .unwrap_or(0)
538 .min(input);
539 if input != 0 || output != 0 {
540 events.push(StreamEvent::Usage {
541 input_tokens: input - cached,
542 output_tokens: output,
543 cache_read_input_tokens: cached,
544 cache_creation_input_tokens: 0,
545 });
546 }
547 }
548
549 events
550}
551
552pub fn parse_anthropic_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
554 match event_type {
555 "content_block_delta" => {
556 let json: serde_json::Value = match serde_json::from_str(data) {
557 Ok(v) => v,
558 Err(_) => return Vec::new(),
559 };
560 let delta = match json.get("delta") {
561 Some(d) => d,
562 None => return Vec::new(),
563 };
564 let delta_type = match delta.get("type").and_then(|t| t.as_str()) {
565 Some(t) => t,
566 None => return Vec::new(),
567 };
568
569 match delta_type {
570 "text_delta" => match delta.get("text").and_then(|t| t.as_str()) {
571 Some(text) => vec![StreamEvent::TextDelta(text.to_string())],
572 None => Vec::new(),
573 },
574 "input_json_delta" => match delta.get("partial_json").and_then(|p| p.as_str()) {
575 Some(partial) => {
576 let index =
577 json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
578 vec![StreamEvent::ToolCallDelta {
579 index,
580 arguments_delta: partial.to_string(),
581 }]
582 }
583 None => Vec::new(),
584 },
585 _ => Vec::new(),
586 }
587 }
588 "content_block_start" => {
589 let json: serde_json::Value = match serde_json::from_str(data) {
590 Ok(v) => v,
591 Err(_) => return Vec::new(),
592 };
593 let block = match json.get("content_block") {
594 Some(b) => b,
595 None => return Vec::new(),
596 };
597 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
598 if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
599 let index = json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
600 let id = block
601 .get("id")
602 .and_then(|i| i.as_str())
603 .map(|s| s.to_string());
604 return vec![StreamEvent::ToolCallStart {
605 name: name.to_string(),
606 index,
607 id,
608 }];
609 }
610 }
611 Vec::new()
612 }
613 "message_start" => {
617 let json: serde_json::Value = match serde_json::from_str(data) {
618 Ok(v) => v,
619 Err(_) => return Vec::new(),
620 };
621 let Some(usage) = json.pointer("/message/usage") else {
622 return Vec::new();
623 };
624 let input = usage
625 .get("input_tokens")
626 .and_then(|n| n.as_u64())
627 .unwrap_or(0);
628 let output = usage
629 .get("output_tokens")
630 .and_then(|n| n.as_u64())
631 .unwrap_or(0);
632 let cache_read = usage
635 .get("cache_read_input_tokens")
636 .and_then(|n| n.as_u64())
637 .unwrap_or(0);
638 let cache_creation = usage
639 .get("cache_creation_input_tokens")
640 .and_then(|n| n.as_u64())
641 .unwrap_or(0);
642 if input == 0 && output == 0 && cache_read == 0 && cache_creation == 0 {
643 return Vec::new();
644 }
645 vec![StreamEvent::Usage {
646 input_tokens: input,
647 output_tokens: output,
648 cache_read_input_tokens: cache_read,
649 cache_creation_input_tokens: cache_creation,
650 }]
651 }
652 "message_delta" => {
656 let json: serde_json::Value = match serde_json::from_str(data) {
657 Ok(v) => v,
658 Err(_) => return Vec::new(),
659 };
660 let mut events = Vec::new();
661 if let Some(reason) = json.pointer("/delta/stop_reason").and_then(|r| r.as_str()) {
664 if !reason.is_empty() {
665 events.push(StreamEvent::StopReason(reason.to_string()));
666 }
667 }
668 if let Some(usage) = json.get("usage") {
669 let input = usage
670 .get("input_tokens")
671 .and_then(|n| n.as_u64())
672 .unwrap_or(0);
673 let output = usage
674 .get("output_tokens")
675 .and_then(|n| n.as_u64())
676 .unwrap_or(0);
677 if input != 0 || output != 0 {
678 events.push(StreamEvent::Usage {
679 input_tokens: input,
680 output_tokens: output,
681 cache_read_input_tokens: 0,
684 cache_creation_input_tokens: 0,
685 });
686 }
687 }
688 events
689 }
690 _ => Vec::new(),
691 }
692}
693
694#[derive(Default)]
696pub struct StreamAccumulator {
697 pub text: String,
698 tool_names: HashMap<usize, String>,
699 tool_args: HashMap<usize, String>,
700 tool_args_fallback: HashMap<usize, String>,
701 tool_args_authoritative: HashMap<usize, String>,
702 tool_ids: HashMap<usize, String>,
703 input_tokens: u64,
708 output_tokens: u64,
712 cache_read_input_tokens: u64,
714 cache_creation_input_tokens: u64,
716 saw_usage: bool,
721 stop_reason: Option<String>,
724 provider_output_items: Vec<serde_json::Value>,
726}
727
728impl StreamAccumulator {
729 pub fn push(&mut self, event: &StreamEvent) {
730 match event {
731 StreamEvent::TextDelta(t) => self.text.push_str(t),
732 StreamEvent::ToolCallStart { name, index, id } => {
733 self.tool_names.insert(*index, name.clone());
734 self.tool_args.entry(*index).or_default();
735 if let Some(id) = id {
736 self.tool_ids.insert(*index, id.clone());
737 }
738 }
739 StreamEvent::ToolCallDelta {
740 index,
741 arguments_delta,
742 } => {
743 self.tool_args
744 .entry(*index)
745 .or_default()
746 .push_str(arguments_delta);
747 }
748 StreamEvent::ToolCallArguments {
749 index,
750 arguments,
751 authoritative,
752 } => {
753 let destination = if *authoritative {
754 &mut self.tool_args_authoritative
755 } else {
756 &mut self.tool_args_fallback
757 };
758 destination.insert(*index, arguments.clone());
759 }
760 StreamEvent::Usage {
761 input_tokens,
762 output_tokens,
763 cache_read_input_tokens,
764 cache_creation_input_tokens,
765 } => {
766 self.saw_usage = true;
767 if *input_tokens > self.input_tokens {
774 self.input_tokens = *input_tokens;
775 }
776 if *output_tokens > self.output_tokens {
777 self.output_tokens = *output_tokens;
778 }
779 if *cache_read_input_tokens > self.cache_read_input_tokens {
780 self.cache_read_input_tokens = *cache_read_input_tokens;
781 }
782 if *cache_creation_input_tokens > self.cache_creation_input_tokens {
783 self.cache_creation_input_tokens = *cache_creation_input_tokens;
784 }
785 }
786 StreamEvent::StopReason(reason) => {
787 self.stop_reason = Some(reason.clone());
788 }
789 StreamEvent::ProviderOutputItem(item) => {
790 self.provider_output_items.push(item.clone());
791 }
792 StreamEvent::Error(_) | StreamEvent::Attribution(_) | StreamEvent::Substituted(_) => {}
793 StreamEvent::Done { .. } => {}
794 }
795 }
796
797 pub fn finish(self) -> (String, Vec<ToolCall>) {
798 let (text, tool_calls, _, _) = self.finish_with_usage();
799 (text, tool_calls)
800 }
801
802 pub fn finish_with_usage(self) -> (String, Vec<ToolCall>, Option<TokenUsage>, Option<String>) {
810 let (text, tool_calls, usage, stop_reason, _) = self.finish_with_provider_output_items();
811 (text, tool_calls, usage, stop_reason)
812 }
813
814 pub fn finish_with_provider_output_items(
817 self,
818 ) -> (
819 String,
820 Vec<ToolCall>,
821 Option<TokenUsage>,
822 Option<String>,
823 Vec<serde_json::Value>,
824 ) {
825 let mut tool_calls = Vec::new();
826 let mut indices: Vec<usize> = self.tool_names.keys().copied().collect();
827 indices.sort();
828
829 for idx in indices {
830 let id = self.tool_ids.get(&idx).cloned();
831 let name = self.tool_names.get(&idx).cloned().unwrap_or_default();
832 let args_str = self
833 .tool_args_authoritative
834 .get(&idx)
835 .or_else(|| {
836 self.tool_args
837 .get(&idx)
838 .filter(|arguments| !arguments.is_empty())
839 })
840 .or_else(|| self.tool_args_fallback.get(&idx))
841 .cloned()
842 .unwrap_or_default();
843 let arguments: HashMap<String, serde_json::Value> = if args_str.trim().is_empty() {
844 HashMap::new()
845 } else {
846 match serde_json::from_str(&args_str) {
847 Ok(arguments) => arguments,
848 Err(error) => {
849 tracing::warn!(
850 tool = %name,
851 output_index = idx,
852 %error,
853 "tool-call arguments were not valid JSON; returning an empty argument map"
854 );
855 HashMap::new()
856 }
857 }
858 };
859 tool_calls.push(ToolCall {
860 id,
861 name,
862 arguments,
863 });
864 }
865
866 let usage = if self.saw_usage {
867 Some(TokenUsage {
868 prompt_tokens: self.input_tokens,
869 completion_tokens: self.output_tokens,
870 total_tokens: self.input_tokens + self.output_tokens,
871 context_window: 0,
875 cache_read_input_tokens: self.cache_read_input_tokens,
878 cache_creation_input_tokens: self.cache_creation_input_tokens,
879 })
880 } else {
881 None
882 };
883
884 let (text, tag_calls) = crate::tasks::generate::parse_tool_calls(&self.text);
890 let (text, tool_calls) = if tool_calls.is_empty() && !tag_calls.is_empty() {
891 (text, tag_calls)
892 } else {
893 (text, tool_calls)
895 };
896
897 (
898 text,
899 tool_calls,
900 usage,
901 self.stop_reason,
902 self.provider_output_items,
903 )
904 }
905}
906
907pub fn parse_sse_lines(chunk: &str) -> Vec<(String, String)> {
910 let mut events = Vec::new();
911 let mut current_event = String::new();
912 let mut current_data = String::new();
913
914 for line in chunk.lines() {
915 if let Some(rest) = line.strip_prefix("event: ") {
916 current_event = rest.to_string();
917 } else if let Some(rest) = line.strip_prefix("data: ") {
918 current_data = rest.to_string();
919 } else if line.is_empty() && !current_data.is_empty() {
920 events.push((
921 if current_event.is_empty() {
922 "message".to_string()
923 } else {
924 current_event.clone()
925 },
926 current_data.clone(),
927 ));
928 current_event.clear();
929 current_data.clear();
930 }
931 }
932
933 if !current_data.is_empty() {
935 events.push((
936 if current_event.is_empty() {
937 "message".to_string()
938 } else {
939 current_event
940 },
941 current_data,
942 ));
943 }
944
945 events
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 #[test]
960 fn accumulated_usage_and_stop_reason_survive_finish() {
961 let mut acc = StreamAccumulator::default();
962 acc.push(&StreamEvent::TextDelta("hello".into()));
963 acc.push(&StreamEvent::Usage {
964 input_tokens: 28,
965 output_tokens: 5,
966 cache_read_input_tokens: 0,
967 cache_creation_input_tokens: 0,
968 });
969 acc.push(&StreamEvent::StopReason("length".into()));
970
971 let (text, _tools, usage, stop) = acc.finish_with_usage();
972 assert_eq!(text, "hello");
973 let usage = usage.expect("a reported Usage event must not be dropped");
974 assert_eq!(usage.prompt_tokens, 28);
975 assert_eq!(usage.completion_tokens, 5);
976 assert_eq!(usage.total_tokens, 33);
977 assert_eq!(
978 stop.as_deref(),
979 Some("length"),
980 "the provider stop_reason feeds was_truncated and was being dropped too"
981 );
982 }
983
984 #[test]
987 fn absent_usage_stays_none_rather_than_zero() {
988 let mut acc = StreamAccumulator::default();
989 acc.push(&StreamEvent::TextDelta("hi".into()));
990 let (_text, _tools, usage, stop) = acc.finish_with_usage();
991 assert!(
992 usage.is_none(),
993 "no Usage event must yield None so callers can fall back to an estimator"
994 );
995 assert!(stop.is_none());
996 }
997
998 #[test]
1006 fn managed_error_events_carry_type_and_code() {
1007 let events = parse_openai_responses_sse_line(
1008 "error",
1009 r#"{"error":{"message":"content refused","type":"invalid_request_error","code":"content_policy_violation"}}"#,
1010 );
1011 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
1012 panic!("expected StreamEvent::Error, got {:?}", events.first());
1013 };
1014 assert!(msg.contains("content refused"), "message dropped: {msg}");
1015 assert!(
1016 msg.contains("type=invalid_request_error"),
1017 "type dropped: {msg}"
1018 );
1019 assert!(
1020 msg.contains("code=content_policy_violation"),
1021 "code dropped: {msg}"
1022 );
1023 }
1024
1025 #[test]
1029 fn a_messageless_managed_error_still_reports_its_code() {
1030 let events = parse_openai_responses_sse_line(
1031 "response.failed",
1032 r#"{"response":{"error":{"code":"content_filter"}}}"#,
1033 );
1034 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
1035 panic!("expected StreamEvent::Error");
1036 };
1037 assert!(msg.contains("managed inference failed"), "{msg}");
1038 assert!(
1039 msg.contains("code=content_filter"),
1040 "classification lost: {msg}"
1041 );
1042 }
1043
1044 #[test]
1047 fn a_bare_managed_error_is_unchanged() {
1048 let events = parse_openai_responses_sse_line("error", r#"{"error":{}}"#);
1049 let StreamEvent::Error(msg) = events.first().expect("an error event") else {
1050 panic!("expected StreamEvent::Error");
1051 };
1052 assert_eq!(msg, "managed inference failed");
1053 }
1054
1055 #[test]
1056 fn parse_openai_text_delta() {
1057 let line = r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#;
1058 let events = parse_openai_sse_line(line);
1059 assert_eq!(events.len(), 1);
1060 match &events[0] {
1061 StreamEvent::TextDelta(t) => assert_eq!(t, "Hello"),
1062 other => panic!("expected TextDelta, got {:?}", other),
1063 }
1064 }
1065
1066 #[test]
1067 fn parse_openai_tool_call_start() {
1068 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"edit_file"}}]}}]}"#;
1069 let events = parse_openai_sse_line(line);
1070 assert_eq!(events.len(), 1);
1071 match &events[0] {
1072 StreamEvent::ToolCallStart { name, index, .. } => {
1073 assert_eq!(name, "edit_file");
1074 assert_eq!(*index, 0);
1075 }
1076 other => panic!("expected ToolCallStart, got {:?}", other),
1077 }
1078 }
1079
1080 #[test]
1081 fn parse_openai_tool_call_delta() {
1082 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}"#;
1083 let events = parse_openai_sse_line(line);
1084 assert_eq!(events.len(), 1);
1085 match &events[0] {
1086 StreamEvent::ToolCallDelta {
1087 index,
1088 arguments_delta,
1089 } => {
1090 assert_eq!(*index, 0);
1091 assert!(arguments_delta.contains("path"));
1092 }
1093 other => panic!("expected ToolCallDelta, got {:?}", other),
1094 }
1095 }
1096
1097 #[test]
1098 fn parse_openai_multiple_tool_calls_in_chunk() {
1099 let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file"}},{"index":1,"function":{"name":"search"}}]}}]}"#;
1101 let events = parse_openai_sse_line(line);
1102 assert_eq!(events.len(), 2);
1103 match &events[0] {
1104 StreamEvent::ToolCallStart { name, index, .. } => {
1105 assert_eq!(name, "read_file");
1106 assert_eq!(*index, 0);
1107 }
1108 other => panic!("expected ToolCallStart, got {:?}", other),
1109 }
1110 match &events[1] {
1111 StreamEvent::ToolCallStart { name, index, .. } => {
1112 assert_eq!(name, "search");
1113 assert_eq!(*index, 1);
1114 }
1115 other => panic!("expected ToolCallStart, got {:?}", other),
1116 }
1117 }
1118
1119 #[test]
1120 fn parse_openai_done() {
1121 assert!(parse_openai_sse_line("data: [DONE]").is_empty());
1122 }
1123
1124 #[test]
1125 fn parse_anthropic_text_delta() {
1126 let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}"#;
1127 let events = parse_anthropic_sse_line("content_block_delta", data);
1128 assert_eq!(events.len(), 1);
1129 match &events[0] {
1130 StreamEvent::TextDelta(t) => assert_eq!(t, "world"),
1131 other => panic!("expected TextDelta, got {:?}", other),
1132 }
1133 }
1134
1135 #[test]
1136 fn parse_anthropic_tool_start() {
1137 let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"search","input":{}}}"#;
1138 let events = parse_anthropic_sse_line("content_block_start", data);
1139 assert_eq!(events.len(), 1);
1140 match &events[0] {
1141 StreamEvent::ToolCallStart { name, index, .. } => {
1142 assert_eq!(name, "search");
1143 assert_eq!(*index, 1);
1144 }
1145 other => panic!("expected ToolCallStart, got {:?}", other),
1146 }
1147 }
1148
1149 #[test]
1150 fn accumulator_builds_result() {
1151 let mut acc = StreamAccumulator::default();
1152 acc.push(&StreamEvent::TextDelta("Hello ".into()));
1153 acc.push(&StreamEvent::TextDelta("world".into()));
1154 acc.push(&StreamEvent::ToolCallStart {
1155 name: "search".into(),
1156 index: 0,
1157 id: None,
1158 });
1159 acc.push(&StreamEvent::ToolCallDelta {
1160 index: 0,
1161 arguments_delta: r#"{"q":"test"}"#.into(),
1162 });
1163
1164 let (text, tools) = acc.finish();
1165 assert_eq!(text, "Hello world");
1166 assert_eq!(tools.len(), 1);
1167 assert_eq!(tools[0].name, "search");
1168 assert!(tools[0].arguments.contains_key("q"));
1169 }
1170
1171 #[test]
1172 fn parse_sse_lines_openai_format() {
1173 let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n";
1174 let events = parse_sse_lines(chunk);
1175 assert_eq!(events.len(), 2);
1176 assert_eq!(events[0].0, "message");
1177 assert_eq!(events[1].1, "[DONE]");
1178 }
1179
1180 #[test]
1181 fn parse_sse_lines_anthropic_format() {
1182 let chunk = "event: content_block_delta\ndata: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n";
1183 let events = parse_sse_lines(chunk);
1184 assert_eq!(events.len(), 1);
1185 assert_eq!(events[0].0, "content_block_delta");
1186 }
1187
1188 #[test]
1189 fn parse_anthropic_message_start_emits_usage() {
1190 let data = r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","usage":{"input_tokens":245,"output_tokens":1}}}"#;
1191 let events = parse_anthropic_sse_line("message_start", data);
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, 1);
1201 }
1202 other => panic!("expected Usage, got {:?}", other),
1203 }
1204 }
1205
1206 #[test]
1207 fn parse_anthropic_message_delta_emits_stop_reason_and_usage() {
1208 let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1209 let events = parse_anthropic_sse_line("message_delta", data);
1210 assert_eq!(events.len(), 2);
1213 match &events[0] {
1214 StreamEvent::StopReason(reason) => assert_eq!(reason, "end_turn"),
1215 other => panic!("expected StopReason, got {:?}", other),
1216 }
1217 match &events[1] {
1218 StreamEvent::Usage {
1219 input_tokens,
1220 output_tokens,
1221 ..
1222 } => {
1223 assert_eq!(*input_tokens, 0);
1224 assert_eq!(*output_tokens, 87);
1225 }
1226 other => panic!("expected Usage, got {:?}", other),
1227 }
1228 }
1229
1230 #[test]
1231 fn parse_anthropic_message_delta_max_tokens_stop_reason() {
1232 let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1235 let events = parse_anthropic_sse_line("message_delta", data);
1236 assert!(matches!(
1237 &events[0],
1238 StreamEvent::StopReason(r) if r == "max_tokens"
1239 ));
1240 }
1241
1242 #[test]
1243 fn parse_openai_finish_reason_length_surfaces() {
1244 let line = r#"data: {"choices":[{"delta":{"content":""},"finish_reason":"length"}]}"#;
1247 let events = parse_openai_sse_line(line);
1248 assert!(events
1249 .iter()
1250 .any(|e| matches!(e, StreamEvent::StopReason(r) if r == "length")));
1251 }
1252
1253 #[test]
1254 fn accumulator_captures_stop_reason() {
1255 let mut acc = StreamAccumulator::default();
1256 acc.push(&StreamEvent::TextDelta("partial".into()));
1257 acc.push(&StreamEvent::StopReason("max_tokens".into()));
1258 let (_, _, _, stop) = acc.finish_with_usage();
1259 assert_eq!(stop.as_deref(), Some("max_tokens"));
1260 }
1261
1262 #[test]
1263 fn parse_anthropic_message_start_without_usage_is_empty() {
1264 let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1266 assert!(parse_anthropic_sse_line("message_start", data).is_empty());
1267 }
1268
1269 #[test]
1270 fn accumulator_tracks_usage_across_anthropic_stream() {
1271 let mut acc = StreamAccumulator::default();
1274 for event in parse_anthropic_sse_line(
1275 "message_start",
1276 r#"{"message":{"usage":{"input_tokens":245,"output_tokens":1}}}"#,
1277 ) {
1278 acc.push(&event);
1279 }
1280 for event in parse_anthropic_sse_line(
1281 "content_block_start",
1282 r#"{"index":0,"content_block":{"type":"text","text":""}}"#,
1283 ) {
1284 acc.push(&event);
1285 }
1286 for (chunk, _) in [
1287 (r#"{"delta":{"type":"text_delta","text":"Hello"}}"#, ()),
1288 (r#"{"delta":{"type":"text_delta","text":", "}}"#, ()),
1289 (r#"{"delta":{"type":"text_delta","text":"world"}}"#, ()),
1290 ] {
1291 for event in parse_anthropic_sse_line("content_block_delta", chunk) {
1292 acc.push(&event);
1293 }
1294 }
1295 for event in parse_anthropic_sse_line("message_delta", r#"{"usage":{"output_tokens":87}}"#)
1296 {
1297 acc.push(&event);
1298 }
1299
1300 let (text, tools, usage, _stop) = acc.finish_with_usage();
1301 assert_eq!(text, "Hello, world");
1302 assert!(tools.is_empty());
1303 let usage = usage.expect("provider reported usage; must surface");
1304 assert_eq!(usage.prompt_tokens, 245);
1305 assert_eq!(usage.completion_tokens, 87);
1307 assert_eq!(usage.total_tokens, 332);
1308 }
1309
1310 #[test]
1311 fn parse_openai_final_chunk_emits_usage() {
1312 let line = r#"data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87,"total_tokens":332}}"#;
1315 let events = parse_openai_sse_line(line);
1316 assert_eq!(events.len(), 1);
1317 match &events[0] {
1318 StreamEvent::Usage {
1319 input_tokens,
1320 output_tokens,
1321 ..
1322 } => {
1323 assert_eq!(*input_tokens, 245);
1324 assert_eq!(*output_tokens, 87);
1325 }
1326 other => panic!("expected Usage, got {:?}", other),
1327 }
1328 }
1329
1330 #[test]
1331 fn accumulator_tracks_usage_across_openai_stream() {
1332 let mut acc = StreamAccumulator::default();
1335 for line in [
1336 r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#,
1337 r#"data: {"choices":[{"delta":{"content":", "}}]}"#,
1338 r#"data: {"choices":[{"delta":{"content":"world"}}]}"#,
1339 r#"data: {"id":"chatcmpl-1","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87}}"#,
1340 ] {
1341 for event in parse_openai_sse_line(line) {
1342 acc.push(&event);
1343 }
1344 }
1345
1346 let (text, tools, usage, _stop) = acc.finish_with_usage();
1347 assert_eq!(text, "Hello, world");
1348 assert!(tools.is_empty());
1349 let usage = usage.expect("provider reported usage; must surface");
1350 assert_eq!(usage.prompt_tokens, 245);
1351 assert_eq!(usage.completion_tokens, 87);
1352 assert_eq!(usage.total_tokens, 332);
1353 }
1354
1355 #[test]
1356 fn accumulator_returns_no_usage_when_provider_silent() {
1357 let mut acc = StreamAccumulator::default();
1361 acc.push(&StreamEvent::TextDelta("hi".into()));
1362 let (_, _, usage, _stop) = acc.finish_with_usage();
1363 assert!(usage.is_none());
1364 }
1365
1366 #[test]
1367 fn anthropic_stream_decodes_cache_tokens_from_message_start() {
1368 let mut acc = StreamAccumulator::default();
1372 let start = r#"{"message":{"usage":{"input_tokens":50,"output_tokens":1,"cache_read_input_tokens":4000,"cache_creation_input_tokens":600}}}"#;
1373 for e in parse_anthropic_sse_line("message_start", start) {
1374 acc.push(&e);
1375 }
1376 let delta = r#"{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1377 for e in parse_anthropic_sse_line("message_delta", delta) {
1378 acc.push(&e);
1379 }
1380 let (_t, _c, usage, stop) = acc.finish_with_usage();
1381 let u = usage.expect("usage surfaced");
1382 assert_eq!(u.prompt_tokens, 50, "uncached prefix");
1383 assert_eq!(u.completion_tokens, 87, "final output from message_delta");
1384 assert_eq!(u.cache_read_input_tokens, 4000);
1385 assert_eq!(u.cache_creation_input_tokens, 600);
1386 assert_eq!(stop.as_deref(), Some("end_turn"));
1387 }
1388
1389 #[test]
1390 fn openai_stream_normalizes_cached_tokens_out_of_prompt() {
1391 let mut acc = StreamAccumulator::default();
1395 let chunk = r#"data: {"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":40,"prompt_tokens_details":{"cached_tokens":800}}}"#;
1396 for e in parse_openai_sse_line(chunk) {
1397 acc.push(&e);
1398 }
1399 let (_t, _c, usage, _s) = acc.finish_with_usage();
1400 let u = usage.expect("usage surfaced");
1401 assert_eq!(u.prompt_tokens, 200, "uncached = 1000 - 800");
1402 assert_eq!(u.cache_read_input_tokens, 800);
1403 assert_eq!(
1404 u.cache_creation_input_tokens, 0,
1405 "OpenAI has no write bucket"
1406 );
1407 }
1408
1409 #[test]
1410 fn responses_failure_is_a_terminal_safe_error_event() {
1411 let events = parse_openai_responses_sse_line(
1412 "response.failed",
1413 r#"{"response":{"error":{"message":"managed model unavailable","stack":"secret"}}}"#,
1414 );
1415 assert!(matches!(
1416 events.as_slice(),
1417 [StreamEvent::Error(message)] if message == "managed model unavailable"
1418 ));
1419 assert!(!format!("{events:?}").contains("secret"));
1420 }
1421
1422 #[test]
1423 fn responses_reasoning_item_done_is_retained_verbatim() {
1424 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"}}"#;
1425 let events = parse_openai_responses_sse_line("response.output_item.done", data);
1426 let expected = serde_json::json!({
1427 "type": "reasoning",
1428 "id": "rs_1",
1429 "status": "completed",
1430 "summary": [{"type": "summary_text", "text": "safe summary"}],
1431 "encrypted_content": "opaque-ciphertext",
1432 });
1433 assert!(matches!(
1434 events.as_slice(),
1435 [StreamEvent::ProviderOutputItem(item)] if item == &expected
1436 ));
1437 let mut accumulator = StreamAccumulator::default();
1438 accumulator.push(&events[0]);
1439 let (_, _, _, _, items) = accumulator.finish_with_provider_output_items();
1440 assert_eq!(items, vec![expected]);
1441 }
1442
1443 #[test]
1444 fn responses_incomplete_is_terminal_failure_not_success() {
1445 let events = parse_openai_responses_sse_line(
1446 "response.incomplete",
1447 r#"{"response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":17,"output_tokens":9}}}"#,
1448 );
1449 assert!(
1450 events
1451 .iter()
1452 .any(|event| matches!(event, StreamEvent::Error(_))),
1453 "response.incomplete must emit a terminal error"
1454 );
1455 }
1456}