1use std::collections::HashMap;
28use std::pin::Pin;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use futures::Stream;
32use serde::Serialize;
33use serde_json::Value;
34use ts_rs::TS;
35
36use crate::error::AiMuxError;
37use crate::parse_tool_call::raw_tool_call_text;
38use crate::result::GenerateContent;
39use crate::result::GenerateResult;
40use crate::shared::FileData;
41use crate::stream_part::StreamPart;
42use crate::types::{FinishReason, FinishReasonUnified, Usage};
43
44#[derive(Debug, Clone, Serialize, TS)]
52#[ts(export)]
53pub struct ChatCompletion {
54 pub id: String,
55 pub object: String,
56 pub created: u64,
57 pub model: String,
58 pub choices: Vec<ChatCompletionChoice>,
59 pub usage: ChatCompletionUsage,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub system_fingerprint: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, TS)]
65#[ts(export)]
66pub struct ChatCompletionChoice {
67 pub index: u32,
68 pub message: ChatCompletionMessage,
69 pub finish_reason: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub logprobs: Option<Value>,
72}
73
74#[derive(Debug, Clone, Serialize, TS)]
75#[ts(export)]
76pub struct ChatCompletionMessage {
77 pub role: String,
78 pub content: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub reasoning_content: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub tool_calls: Option<Vec<ChatCompletionToolCall>>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub annotations: Option<Vec<Value>>,
85}
86
87#[derive(Debug, Clone, Serialize, TS)]
88#[ts(export)]
89pub struct ChatCompletionToolCall {
90 pub id: String,
91 #[serde(rename = "type")]
92 pub tool_type: String,
93 pub function: ChatCompletionFunction,
94}
95
96#[derive(Debug, Clone, Serialize, TS)]
97#[ts(export)]
98pub struct ChatCompletionFunction {
99 pub name: String,
100 pub arguments: String,
101}
102
103#[derive(Debug, Clone, Serialize, TS)]
111#[ts(export)]
112pub struct ChatCompletionChunk {
113 pub id: String,
114 pub object: String,
115 pub created: u64,
116 pub model: String,
117 pub choices: Vec<ChatCompletionChunkChoice>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub usage: Option<ChatCompletionUsage>,
120}
121
122#[derive(Debug, Clone, Serialize, TS)]
123#[ts(export)]
124pub struct ChatCompletionChunkChoice {
125 pub index: u32,
126 pub delta: ChatCompletionDelta,
127 pub finish_reason: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub logprobs: Option<Value>,
130}
131
132#[derive(Debug, Clone, Default, Serialize, TS)]
133#[ts(export)]
134pub struct ChatCompletionDelta {
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub role: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub content: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub reasoning_content: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub tool_calls: Option<Vec<ChatCompletionChunkToolCall>>,
143}
144
145#[derive(Debug, Clone, Serialize, TS)]
146#[ts(export)]
147pub struct ChatCompletionChunkToolCall {
148 pub index: u32,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub id: Option<String>,
151 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
152 pub tool_type: Option<String>,
153 pub function: ChatCompletionChunkFunction,
154}
155
156#[derive(Debug, Clone, Default, Serialize, TS)]
157#[ts(export)]
158pub struct ChatCompletionChunkFunction {
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub name: Option<String>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub arguments: Option<String>,
163}
164
165#[derive(Debug, Clone, Default, Serialize, TS)]
170#[ts(export)]
171pub struct ChatCompletionUsage {
172 pub prompt_tokens: u32,
173 pub completion_tokens: u32,
174 pub total_tokens: u32,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub prompt_tokens_details: Option<PromptTokensDetails>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub completion_tokens_details: Option<CompletionTokensDetails>,
179}
180
181#[derive(Debug, Clone, Default, Serialize, TS)]
182#[ts(export)]
183pub struct PromptTokensDetails {
184 pub cached_tokens: u32,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub cache_write_tokens: Option<u32>,
187}
188
189#[derive(Debug, Clone, Default, Serialize, TS)]
190#[ts(export)]
191pub struct CompletionTokensDetails {
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub reasoning_tokens: Option<u32>,
194}
195
196#[derive(Debug, Clone)]
202pub struct OpenAiStreamOptions {
203 pub include_usage: bool,
206 pub include_reasoning: bool,
208}
209
210impl Default for OpenAiStreamOptions {
211 fn default() -> Self {
212 Self {
213 include_usage: true,
214 include_reasoning: true,
215 }
216 }
217}
218
219#[must_use]
229pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletion {
230 let mut content_text = String::new();
231 let mut reasoning_text = String::new();
232 let mut tool_calls = Vec::new();
233 let mut annotations = Vec::new();
234
235 for item in &result.content {
236 match item {
237 GenerateContent::Text { text, .. } => {
238 content_text.push_str(text);
239 }
240 GenerateContent::Reasoning { text, .. } => {
241 reasoning_text.push_str(text);
242 }
243 GenerateContent::ToolCall {
244 tool_call_id,
245 tool_name,
246 input,
247 ..
248 } => {
249 let arguments = if input.trim().is_empty() {
253 "{}".to_string()
254 } else {
255 input.clone()
256 };
257 tool_calls.push(ChatCompletionToolCall {
258 id: tool_call_id.clone(),
259 tool_type: "function".to_string(),
260 function: ChatCompletionFunction {
261 name: tool_name.clone(),
262 arguments,
263 },
264 });
265 }
266 GenerateContent::Source { url, title, .. } => {
267 let mut ann = serde_json::json!({
269 "type": "url_citation",
270 "url_citation": {
271 "url": url.clone().unwrap_or_default(),
272 }
273 });
274 if let Some(t) = title {
275 ann["url_citation"]["title"] = serde_json::Value::String(t.clone());
276 }
277 annotations.push(ann);
278 if let Some(u) = url {
280 if !content_text.is_empty() {
281 content_text.push('\n');
282 }
283 content_text.push_str(u);
284 }
285 }
286 GenerateContent::File {
287 data, media_type, ..
288 } => {
289 if !content_text.is_empty() {
290 content_text.push('\n');
291 }
292 content_text.push_str(&file_data_to_text(data, media_type));
293 }
294 GenerateContent::ToolResult {
295 tool_name,
296 result,
297 is_error,
298 ..
299 } => {
300 if !content_text.is_empty() {
302 content_text.push('\n');
303 }
304 let prefix = if is_error.unwrap_or(false) {
305 format!("[tool error: {tool_name}] ")
306 } else {
307 format!("[tool result: {tool_name}] ")
308 };
309 content_text.push_str(&prefix);
310 content_text.push_str(&result.to_string());
311 }
312 }
313 }
314
315 let content = if content_text.is_empty() && !tool_calls.is_empty() {
317 None
318 } else {
319 Some(content_text)
320 };
321
322 let message = ChatCompletionMessage {
323 role: "assistant".to_string(),
324 content,
325 reasoning_content: if reasoning_text.is_empty() {
326 None
327 } else {
328 Some(reasoning_text)
329 },
330 tool_calls: if tool_calls.is_empty() {
331 None
332 } else {
333 Some(tool_calls)
334 },
335 annotations: if annotations.is_empty() {
336 None
337 } else {
338 Some(annotations)
339 },
340 };
341
342 let finish_reason = finish_reason_to_openai(&result.finish_reason);
344
345 let logprobs = result
347 .provider_metadata
348 .as_ref()
349 .and_then(|pm| pm.get("openai"))
350 .and_then(|o| o.get("logprobs"))
351 .cloned();
352
353 let id = result
355 .response
356 .id
357 .clone()
358 .unwrap_or_else(|| format!("chatcmpl-{}", random_id()));
359 let model = result
360 .response
361 .model_id
362 .clone()
363 .unwrap_or_else(|| model.to_string());
364 let created = now_unix();
365
366 ChatCompletion {
367 id,
368 object: "chat.completion".to_string(),
369 created,
370 model,
371 choices: vec![ChatCompletionChoice {
372 index: 0,
373 message,
374 finish_reason,
375 logprobs,
376 }],
377 usage: usage_to_openai(&result.usage),
378 system_fingerprint: None,
379 }
380}
381
382pub struct ChatCompletionStream {
388 pub stream: Pin<Box<dyn Stream<Item = Result<ChatCompletionChunk, AiMuxError>> + Send>>,
390}
391
392impl std::fmt::Debug for ChatCompletionStream {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 f.debug_struct("ChatCompletionStream")
395 .field("stream", &"<stream>")
396 .finish()
397 }
398}
399
400#[must_use]
411pub fn to_chat_completion_stream(
412 stream: Pin<Box<dyn Stream<Item = Result<StreamPart, AiMuxError>> + Send>>,
413 model: &str,
414 options: OpenAiStreamOptions,
415) -> ChatCompletionStream {
416 let model = model.to_string();
417 let include_usage = options.include_usage;
418 let include_reasoning = options.include_reasoning;
419
420 let chunk_stream = async_stream::stream! {
421 let mut state = StreamState::new(model.clone());
422
423 use futures::StreamExt;
424 let mut stream = stream;
425
426 while let Some(part_result) = stream.next().await {
427 let part = match part_result {
428 Ok(p) => p,
429 Err(e) if e.is_recoverable_stream_error() => {
430 yield Err(e);
431 continue;
432 }
433 Err(e) => {
434 if let Some(chunk) = state.error_chunk(&e) {
436 yield Ok(chunk);
437 }
438 if let Some(chunk) = state.final_chunk(include_usage) {
439 yield Ok(chunk);
440 }
441 return;
442 }
443 };
444
445 let chunks = state.process_part(&part, include_reasoning, include_usage);
446 for chunk in chunks {
447 yield Ok(chunk);
448 }
449
450 if matches!(part, StreamPart::Finish { .. }) {
452 return;
453 }
454 }
455
456 if let Some(chunk) = state.final_chunk(include_usage) {
458 yield Ok(chunk);
459 }
460 };
461
462 ChatCompletionStream {
463 stream: Box::pin(chunk_stream),
464 }
465}
466
467struct StreamState {
469 id: String,
470 model: String,
471 created: u64,
472 started: bool,
473 tool_calls: HashMap<String, ToolCallAccum>,
475 tool_call_order: Vec<String>,
476 next_tool_index: u32,
477 tool_call_opened: std::collections::HashSet<String>,
479 final_usage: Option<Usage>,
480 final_finish_reason: Option<FinishReason>,
481 finish_emitted: bool,
482}
483
484#[allow(dead_code)]
485struct ToolCallAccum {
486 index: u32,
487 id: String,
488 name: String,
489 arguments: String,
491}
492
493impl StreamState {
494 fn new(model: String) -> Self {
495 Self {
496 id: format!("chatcmpl-{}", random_id()),
497 model,
498 created: now_unix(),
499 started: false,
500 tool_calls: HashMap::new(),
501 tool_call_order: Vec::new(),
502 next_tool_index: 0,
503 tool_call_opened: std::collections::HashSet::new(),
504 final_usage: None,
505 final_finish_reason: None,
506 finish_emitted: false,
507 }
508 }
509
510 fn base_chunk(&self) -> ChatCompletionChunk {
512 ChatCompletionChunk {
513 id: self.id.clone(),
514 object: "chat.completion.chunk".to_string(),
515 created: self.created,
516 model: self.model.clone(),
517 choices: Vec::new(),
518 usage: None,
519 }
520 }
521
522 fn ensure_started(&mut self) -> Option<ChatCompletionChunk> {
524 if self.started {
525 return None;
526 }
527 self.started = true;
528 let mut chunk = self.base_chunk();
529 chunk.choices = vec![ChatCompletionChunkChoice {
530 index: 0,
531 delta: ChatCompletionDelta {
532 role: Some("assistant".to_string()),
533 content: Some(String::new()),
534 ..Default::default()
535 },
536 finish_reason: None,
537 logprobs: None,
538 }];
539 Some(chunk)
540 }
541
542 fn process_part(
544 &mut self,
545 part: &StreamPart,
546 include_reasoning: bool,
547 include_usage: bool,
548 ) -> Vec<ChatCompletionChunk> {
549 let mut chunks = Vec::new();
550
551 match part {
552 StreamPart::StreamStart { .. } => {
553 if let Some(c) = self.ensure_started() {
554 chunks.push(c);
555 }
556 }
557
558 StreamPart::ResponseMetadata { id, model_id, .. } => {
559 if let Some(id) = id {
560 self.id = id.clone();
561 }
562 if let Some(m) = model_id {
563 self.model = m.clone();
564 }
565 }
566
567 StreamPart::TextStart { .. } => {
568 if let Some(c) = self.ensure_started() {
569 chunks.push(c);
570 }
571 }
572 StreamPart::TextDelta { delta, .. } => {
573 if let Some(c) = self.ensure_started() {
574 chunks.push(c);
575 }
576 let mut chunk = self.base_chunk();
577 chunk.choices = vec![ChatCompletionChunkChoice {
578 index: 0,
579 delta: ChatCompletionDelta {
580 content: Some(delta.clone()),
581 ..Default::default()
582 },
583 finish_reason: None,
584 logprobs: None,
585 }];
586 chunks.push(chunk);
587 }
588 StreamPart::TextEnd { .. } => {}
589
590 StreamPart::ReasoningStart { .. } => {}
591 StreamPart::ReasoningDelta { delta, .. } => {
592 if include_reasoning {
593 if let Some(c) = self.ensure_started() {
594 chunks.push(c);
595 }
596 let mut chunk = self.base_chunk();
597 chunk.choices = vec![ChatCompletionChunkChoice {
598 index: 0,
599 delta: ChatCompletionDelta {
600 reasoning_content: Some(delta.clone()),
601 ..Default::default()
602 },
603 finish_reason: None,
604 logprobs: None,
605 }];
606 chunks.push(chunk);
607 }
608 }
609 StreamPart::ReasoningEnd { .. } => {}
610
611 StreamPart::ToolInputStart { id, tool_name, .. } => {
612 if let Some(c) = self.ensure_started() {
613 chunks.push(c);
614 }
615 let index = if self.tool_calls.contains_key(id) {
617 self.tool_calls[id].index
618 } else {
619 let idx = self.next_tool_index;
620 self.next_tool_index += 1;
621 self.tool_calls.insert(
622 id.clone(),
623 ToolCallAccum {
624 index: idx,
625 id: id.clone(),
626 name: tool_name.clone(),
627 arguments: String::new(),
628 },
629 );
630 self.tool_call_order.push(id.clone());
631 idx
632 };
633 self.tool_call_opened.insert(id.clone());
634
635 let mut chunk = self.base_chunk();
636 chunk.choices = vec![ChatCompletionChunkChoice {
637 index: 0,
638 delta: ChatCompletionDelta {
639 tool_calls: Some(vec![ChatCompletionChunkToolCall {
640 index,
641 id: Some(id.clone()),
642 tool_type: Some("function".to_string()),
643 function: ChatCompletionChunkFunction {
644 name: Some(tool_name.clone()),
645 arguments: Some(String::new()),
646 },
647 }]),
648 ..Default::default()
649 },
650 finish_reason: None,
651 logprobs: None,
652 }];
653 chunks.push(chunk);
654 }
655 StreamPart::ToolInputDelta { id, delta, .. } => {
656 if let Some(c) = self.ensure_started() {
658 chunks.push(c);
659 }
660 let index = match self.tool_calls.get_mut(id) {
661 Some(acc) => {
662 acc.arguments.push_str(delta);
663 acc.index
664 }
665 None => {
666 let idx = self.next_tool_index;
668 self.next_tool_index += 1;
669 self.tool_calls.insert(
670 id.clone(),
671 ToolCallAccum {
672 index: idx,
673 id: id.clone(),
674 name: String::new(),
675 arguments: delta.clone(),
676 },
677 );
678 self.tool_call_order.push(id.clone());
679 idx
680 }
681 };
682
683 let mut chunk = self.base_chunk();
684 chunk.choices = vec![ChatCompletionChunkChoice {
685 index: 0,
686 delta: ChatCompletionDelta {
687 tool_calls: Some(vec![ChatCompletionChunkToolCall {
688 index,
689 id: None,
690 tool_type: None,
691 function: ChatCompletionChunkFunction {
692 name: None,
693 arguments: Some(delta.clone()),
694 },
695 }]),
696 ..Default::default()
697 },
698 finish_reason: None,
699 logprobs: None,
700 }];
701 chunks.push(chunk);
702 }
703 StreamPart::ToolInputEnd { .. } => {}
704
705 StreamPart::ToolCall {
706 tool_call_id,
707 tool_name,
708 input,
709 invalid,
710 error,
711 ..
712 } => {
713 if self.tool_call_opened.contains(tool_call_id) {
718 let full_arguments =
719 parsed_tool_call_arguments(input, *invalid, error.as_ref());
720 let missing_arguments = self
721 .tool_calls
722 .get(tool_call_id)
723 .and_then(|acc| full_arguments.strip_prefix(&acc.arguments))
724 .unwrap_or_default()
725 .to_string();
726
727 if !missing_arguments.is_empty() {
728 let index = self.tool_calls[tool_call_id].index;
729 if let Some(acc) = self.tool_calls.get_mut(tool_call_id) {
730 acc.arguments.push_str(&missing_arguments);
731 }
732 let mut chunk = self.base_chunk();
733 chunk.choices = vec![ChatCompletionChunkChoice {
734 index: 0,
735 delta: ChatCompletionDelta {
736 tool_calls: Some(vec![ChatCompletionChunkToolCall {
737 index,
738 id: None,
739 tool_type: None,
740 function: ChatCompletionChunkFunction {
741 name: None,
742 arguments: Some(missing_arguments),
743 },
744 }]),
745 ..Default::default()
746 },
747 finish_reason: None,
748 logprobs: None,
749 }];
750 chunks.push(chunk);
751 }
752 } else {
753 if let Some(c) = self.ensure_started() {
754 chunks.push(c);
755 }
756 let index = self.next_tool_index;
757 self.next_tool_index += 1;
758 self.tool_calls.insert(
759 tool_call_id.clone(),
760 ToolCallAccum {
761 index,
762 id: tool_call_id.clone(),
763 name: tool_name.clone(),
764 arguments: parsed_tool_call_arguments(input, *invalid, error.as_ref()),
765 },
766 );
767 self.tool_call_order.push(tool_call_id.clone());
768 self.tool_call_opened.insert(tool_call_id.clone());
769
770 let arguments = self.tool_calls[tool_call_id].arguments.clone();
771
772 let mut chunk = self.base_chunk();
773 chunk.choices = vec![ChatCompletionChunkChoice {
774 index: 0,
775 delta: ChatCompletionDelta {
776 tool_calls: Some(vec![ChatCompletionChunkToolCall {
777 index,
778 id: Some(tool_call_id.clone()),
779 tool_type: Some("function".to_string()),
780 function: ChatCompletionChunkFunction {
781 name: Some(tool_name.clone()),
782 arguments: Some(arguments),
783 },
784 }]),
785 ..Default::default()
786 },
787 finish_reason: None,
788 logprobs: None,
789 }];
790 chunks.push(chunk);
791 }
792 }
793
794 StreamPart::ToolResult {
795 tool_name, result, ..
796 } => {
797 if let Some(c) = self.ensure_started() {
799 chunks.push(c);
800 }
801 let text = format!("[tool result: {tool_name}] {result}");
802 let mut chunk = self.base_chunk();
803 chunk.choices = vec![ChatCompletionChunkChoice {
804 index: 0,
805 delta: ChatCompletionDelta {
806 content: Some(text),
807 ..Default::default()
808 },
809 finish_reason: None,
810 logprobs: None,
811 }];
812 chunks.push(chunk);
813 }
814
815 StreamPart::File {
816 data, media_type, ..
817 } => {
818 if let Some(c) = self.ensure_started() {
819 chunks.push(c);
820 }
821 let text = file_data_to_text(data, media_type);
822 let mut chunk = self.base_chunk();
823 chunk.choices = vec![ChatCompletionChunkChoice {
824 index: 0,
825 delta: ChatCompletionDelta {
826 content: Some(text),
827 ..Default::default()
828 },
829 finish_reason: None,
830 logprobs: None,
831 }];
832 chunks.push(chunk);
833 }
834
835 StreamPart::Source { url, .. } => {
836 if let Some(c) = self.ensure_started() {
837 chunks.push(c);
838 }
839 let text = url.clone().unwrap_or_default();
840 if !text.is_empty() {
841 let mut chunk = self.base_chunk();
842 chunk.choices = vec![ChatCompletionChunkChoice {
843 index: 0,
844 delta: ChatCompletionDelta {
845 content: Some(text),
846 ..Default::default()
847 },
848 finish_reason: None,
849 logprobs: None,
850 }];
851 chunks.push(chunk);
852 }
853 }
854
855 StreamPart::Finish {
856 finish_reason,
857 usage,
858 ..
859 } => {
860 self.final_usage = Some(usage.clone());
861 self.final_finish_reason = Some(finish_reason.clone());
862 if let Some(c) = self.final_chunk_impl(include_usage) {
864 chunks.push(c);
865 }
866 }
867
868 StreamPart::Error { error } => {
869 if let Some(chunk) = self.error_chunk(error) {
870 chunks.push(chunk);
871 }
872 }
873
874 StreamPart::Raw { .. } => { }
875 }
876
877 chunks
878 }
879
880 fn error_chunk(&mut self, error: &AiMuxError) -> Option<ChatCompletionChunk> {
882 if self.finish_emitted {
883 return None;
884 }
885 self.started = true;
886 let mut chunk = self.base_chunk();
887 chunk.choices = vec![ChatCompletionChunkChoice {
888 index: 0,
889 delta: ChatCompletionDelta {
890 content: Some(format!("[error] {error}")),
891 ..Default::default()
892 },
893 finish_reason: None,
894 logprobs: None,
895 }];
896 Some(chunk)
897 }
898
899 fn final_chunk(&mut self, include_usage: bool) -> Option<ChatCompletionChunk> {
901 if self.finish_emitted {
902 return None;
903 }
904 self.final_chunk_impl(include_usage)
905 }
906
907 fn final_chunk_impl(&mut self, include_usage: bool) -> Option<ChatCompletionChunk> {
908 if self.finish_emitted {
909 return None;
910 }
911 self.finish_emitted = true;
912 if !self.started {
913 self.started = true;
914 }
915
916 let finish_reason_str = self
917 .final_finish_reason
918 .as_ref()
919 .map(finish_reason_to_openai)
920 .unwrap_or(Some("stop".to_string()));
921
922 let usage = if include_usage {
923 self.final_usage.as_ref().map(usage_to_openai)
924 } else {
925 None
926 };
927
928 let mut chunk = self.base_chunk();
929 chunk.choices = vec![ChatCompletionChunkChoice {
930 index: 0,
931 delta: ChatCompletionDelta::default(),
932 finish_reason: finish_reason_str,
933 logprobs: None,
934 }];
935 chunk.usage = usage;
936 Some(chunk)
937 }
938}
939
940#[must_use]
946pub fn encode_chunk_sse(chunk: &ChatCompletionChunk) -> String {
947 let json = serde_json::to_string(chunk).unwrap_or_else(|_| "{}".to_string());
948 format!("data: {json}\n\n")
949}
950
951pub const DONE_FRAME: &str = "data: [DONE]\n\n";
953
954fn finish_reason_to_openai(reason: &FinishReason) -> Option<String> {
960 match reason.unified {
961 FinishReasonUnified::Stop => Some("stop".to_string()),
962 FinishReasonUnified::Length => Some("length".to_string()),
963 FinishReasonUnified::ContentFilter => Some("content_filter".to_string()),
964 FinishReasonUnified::ToolCalls => Some("tool_calls".to_string()),
965 FinishReasonUnified::Error => Some("stop".to_string()),
966 FinishReasonUnified::Other => reason.raw.clone().or(Some("stop".to_string())),
967 }
968}
969
970fn usage_to_openai(usage: &Usage) -> ChatCompletionUsage {
975 let prompt_tokens = usage.input_tokens.total.unwrap_or(0);
976 let completion_tokens = usage.output_tokens.total.unwrap_or(0);
977
978 let cached_tokens = usage.input_tokens.cache_read.unwrap_or(0);
979 let cache_write = usage.input_tokens.cache_write;
980 let reasoning_tokens = usage.output_tokens.reasoning;
981
982 ChatCompletionUsage {
983 prompt_tokens,
984 completion_tokens,
985 total_tokens: prompt_tokens + completion_tokens,
986 prompt_tokens_details: Some(PromptTokensDetails {
987 cached_tokens,
988 cache_write_tokens: cache_write,
989 }),
990 completion_tokens_details: Some(CompletionTokensDetails { reasoning_tokens }),
991 }
992}
993
994fn file_data_to_text(data: &FileData, media_type: &str) -> String {
996 match data {
997 FileData::Url { url } => url.clone(),
998 FileData::Data { data } => match data {
999 crate::shared::FileBytes::Base64(b64) => {
1000 format!("data:{media_type};base64,{b64}")
1001 }
1002 crate::shared::FileBytes::Binary(bytes) => {
1003 use base64::Engine;
1004 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
1005 format!("data:{media_type};base64,{b64}")
1006 }
1007 },
1008 FileData::Reference { reference } => serde_json::to_string(reference).unwrap_or_default(),
1009 FileData::Text { text } => text.clone(),
1010 }
1011}
1012
1013fn now_unix() -> u64 {
1015 SystemTime::now()
1016 .duration_since(UNIX_EPOCH)
1017 .map(|d| d.as_secs())
1018 .unwrap_or(0)
1019}
1020
1021pub(crate) fn parsed_tool_call_arguments(
1030 input: &Value,
1031 invalid: Option<bool>,
1032 error: Option<&AiMuxError>,
1033) -> String {
1034 if invalid == Some(true)
1035 && let Some(error) = error
1036 && let Some(raw) = raw_tool_call_text(error)
1037 {
1038 return if raw.trim().is_empty() {
1043 "{}".to_string()
1044 } else {
1045 raw
1046 };
1047 }
1048 input.to_string()
1051}
1052
1053fn random_id() -> String {
1055 use std::sync::atomic::{AtomicU64, Ordering};
1059 static COUNTER: AtomicU64 = AtomicU64::new(0);
1060 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
1061 let ts = now_unix();
1062 format!("{ts:012x}{count:012x}")
1063}
1064
1065#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use crate::result::GenerateResult;
1073 use crate::types::TokenUsage;
1074 use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage};
1075 use futures::StreamExt;
1076 use serde_json::json;
1077
1078 fn make_result(content: Vec<GenerateContent>) -> GenerateResult {
1079 GenerateResult {
1080 content,
1081 finish_reason: FinishReason {
1082 unified: FinishReasonUnified::Stop,
1083 raw: None,
1084 },
1085 usage: Usage::default(),
1086 warnings: vec![],
1087 provider_metadata: None,
1088 response: ResponseMetadata {
1089 id: Some("chatcmpl-test123".to_string()),
1090 timestamp: None,
1091 model_id: Some("gpt-4o".to_string()),
1092 },
1093 request_body: None,
1094 response_headers: None,
1095 }
1096 }
1097
1098 #[test]
1099 fn test_text_only_non_streaming() {
1100 let result = make_result(vec![GenerateContent::Text {
1101 text: "Hello world".to_string(),
1102 provider_metadata: None,
1103 }]);
1104 let completion = to_chat_completion(&result, "gpt-4o");
1105
1106 assert_eq!(completion.object, "chat.completion");
1107 assert_eq!(completion.id, "chatcmpl-test123");
1108 assert_eq!(completion.model, "gpt-4o");
1109 assert_eq!(completion.choices.len(), 1);
1110 assert_eq!(completion.choices[0].index, 0);
1111 assert_eq!(
1112 completion.choices[0].message.content.as_deref(),
1113 Some("Hello world")
1114 );
1115 assert_eq!(completion.choices[0].message.role, "assistant");
1116 assert_eq!(completion.choices[0].finish_reason.as_deref(), Some("stop"));
1117 assert!(completion.choices[0].message.tool_calls.is_none());
1118 assert!(completion.choices[0].message.reasoning_content.is_none());
1119 }
1120
1121 #[test]
1122 fn test_tool_call_non_streaming() {
1123 let result = make_result(vec![
1124 GenerateContent::Text {
1125 text: "Let me check.".to_string(),
1126 provider_metadata: None,
1127 },
1128 GenerateContent::ToolCall {
1129 tool_call_id: "call_abc".to_string(),
1130 tool_name: "get_weather".to_string(),
1131 input: r#"{"city":"Tokyo"}"#.to_string(),
1132 provider_executed: None,
1133 dynamic: None,
1134 thought_signature: None,
1135 provider_metadata: None,
1136 },
1137 ]);
1138 let mut result = result;
1140 result.finish_reason = FinishReason {
1141 unified: FinishReasonUnified::ToolCalls,
1142 raw: None,
1143 };
1144
1145 let completion = to_chat_completion(&result, "gpt-4o");
1146
1147 let msg = &completion.choices[0].message;
1148 assert_eq!(msg.content.as_deref(), Some("Let me check."));
1149 let tool_calls = msg.tool_calls.as_ref().unwrap();
1150 assert_eq!(tool_calls.len(), 1);
1151 assert_eq!(tool_calls[0].id, "call_abc");
1152 assert_eq!(tool_calls[0].tool_type, "function");
1153 assert_eq!(tool_calls[0].function.name, "get_weather");
1154 assert_eq!(tool_calls[0].function.arguments, r#"{"city":"Tokyo"}"#);
1155 assert_eq!(
1156 completion.choices[0].finish_reason.as_deref(),
1157 Some("tool_calls")
1158 );
1159 }
1160
1161 #[test]
1162 fn test_raw_tool_call_arguments_are_not_double_encoded() {
1163 let result = make_result(vec![GenerateContent::ToolCall {
1164 tool_call_id: "call_raw".to_string(),
1165 tool_name: "get_weather".to_string(),
1166 input: r#"{"city":"Tokyo"}"#.to_string(),
1167 provider_executed: None,
1168 dynamic: None,
1169 thought_signature: None,
1170 provider_metadata: None,
1171 }]);
1172
1173 let completion = to_chat_completion(&result, "gpt-4o");
1174 assert_eq!(
1175 completion.choices[0].message.tool_calls.as_ref().unwrap()[0]
1176 .function
1177 .arguments,
1178 r#"{"city":"Tokyo"}"#
1179 );
1180 }
1181
1182 #[test]
1183 fn blank_invalid_tool_arguments_render_as_an_empty_object() {
1184 let error = AiMuxError::InvalidToolInput {
1187 tool_name: "get_weather".to_string(),
1188 tool_input: String::new(),
1189 cause: "Type validation failed: missing 'city'".to_string(),
1190 };
1191 let arguments = parsed_tool_call_arguments(&json!({}), Some(true), Some(&error));
1192 assert_eq!(arguments, "{}");
1193
1194 let result = make_result(vec![GenerateContent::ToolCall {
1195 tool_call_id: "call_blank".to_string(),
1196 tool_name: "get_weather".to_string(),
1197 input: " ".to_string(),
1198 provider_executed: None,
1199 dynamic: None,
1200 thought_signature: None,
1201 provider_metadata: None,
1202 }]);
1203 assert_eq!(
1204 to_chat_completion(&result, "gpt-4o").choices[0]
1205 .message
1206 .tool_calls
1207 .as_ref()
1208 .unwrap()[0]
1209 .function
1210 .arguments,
1211 "{}"
1212 );
1213 }
1214
1215 #[test]
1216 fn parsed_tool_call_arguments_null_is_not_rewritten_to_empty_object() {
1217 let arguments = parsed_tool_call_arguments(&Value::Null, None, None);
1220 assert_eq!(arguments, "null");
1221 }
1222
1223 #[tokio::test]
1230 async fn invalid_tool_calls_stream_their_raw_arguments_verbatim() {
1231 let failing_repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async {
1232 Err(AiMuxError::Other("repair model failed".to_string()))
1233 });
1234 let cases: [(&str, &str, Option<&crate::parse_tool_call::ToolCallRepair>); 3] = [
1235 ("get_weather", r#""hello""#, None),
1238 ("unknown_tool", r#"{"a":"#, None),
1241 ("get_weather", r#"{"a":"#, Some(&failing_repair)),
1243 ];
1244 let tools = [crate::tool::Tool::Function(crate::tool::FunctionTool::new(
1245 "get_weather",
1246 json!({"type": "object", "properties": {}, "additionalProperties": false}),
1247 ))];
1248
1249 for (tool_name, raw_input, repair) in cases {
1250 let parsed = crate::parse_tool_call::parse_tool_call(
1251 crate::parse_tool_call::RawToolCall {
1252 tool_call_id: "call_1".to_string(),
1253 tool_name: tool_name.to_string(),
1254 input: raw_input.to_string(),
1255 provider_executed: None,
1256 dynamic: None,
1257 thought_signature: None,
1258 provider_metadata: None,
1259 },
1260 Some(&tools),
1261 repair,
1262 &[],
1263 None,
1264 )
1265 .await;
1266 assert_eq!(parsed.invalid, Some(true), "{raw_input}");
1267
1268 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![Ok(StreamPart::ToolCall {
1269 tool_call_id: parsed.tool_call_id,
1270 tool_name: parsed.tool_name,
1271 input: parsed.input,
1272 provider_executed: parsed.provider_executed,
1273 dynamic: parsed.dynamic,
1274 thought_signature: parsed.thought_signature,
1275 invalid: parsed.invalid,
1276 error: parsed.error,
1277 provider_metadata: parsed.provider_metadata,
1278 })];
1279 let chunks = collect_stream(to_chat_completion_stream(
1280 Box::pin(futures::stream::iter(parts)),
1281 "gpt-4o",
1282 OpenAiStreamOptions::default(),
1283 ))
1284 .await;
1285
1286 let arguments = chunks
1287 .iter()
1288 .find_map(|c| {
1289 c.choices
1290 .first()?
1291 .delta
1292 .tool_calls
1293 .as_ref()?
1294 .first()?
1295 .function
1296 .arguments
1297 .clone()
1298 })
1299 .expect("tool call arguments chunk not found");
1300 assert_eq!(arguments, raw_input, "tool {tool_name}");
1301 }
1302 }
1303
1304 #[test]
1305 fn test_tool_call_null_content() {
1306 let result = make_result(vec![GenerateContent::ToolCall {
1308 tool_call_id: "call_abc".to_string(),
1309 tool_name: "get_weather".to_string(),
1310 input: r#"{"city":"Tokyo"}"#.to_string(),
1311 provider_executed: None,
1312 dynamic: None,
1313 thought_signature: None,
1314 provider_metadata: None,
1315 }]);
1316 let completion = to_chat_completion(&result, "gpt-4o");
1317
1318 assert_eq!(completion.choices[0].message.content, None);
1319 assert!(completion.choices[0].message.tool_calls.is_some());
1320 }
1321
1322 #[test]
1323 fn test_reasoning_non_streaming() {
1324 let result = make_result(vec![
1325 GenerateContent::Reasoning {
1326 text: "Thinking...".to_string(),
1327 provider_metadata: None,
1328 },
1329 GenerateContent::Text {
1330 text: "Answer".to_string(),
1331 provider_metadata: None,
1332 },
1333 ]);
1334 let completion = to_chat_completion(&result, "gpt-4o");
1335
1336 assert_eq!(
1337 completion.choices[0].message.reasoning_content.as_deref(),
1338 Some("Thinking...")
1339 );
1340 assert_eq!(
1341 completion.choices[0].message.content.as_deref(),
1342 Some("Answer")
1343 );
1344 }
1345
1346 #[test]
1347 fn test_usage_round_trip() {
1348 let usage = Usage {
1349 input_tokens: TokenUsage {
1350 total: Some(100),
1351 cache_read: Some(30),
1352 cache_write: Some(10),
1353 ..Default::default()
1354 },
1355 output_tokens: TokenUsage {
1356 total: Some(50),
1357 reasoning: Some(20),
1358 ..Default::default()
1359 },
1360 raw: None,
1361 };
1362 let mut result = make_result(vec![]);
1363 result.usage = usage;
1364
1365 let completion = to_chat_completion(&result, "gpt-4o");
1366 let u = &completion.usage;
1367
1368 assert_eq!(u.prompt_tokens, 100);
1369 assert_eq!(u.completion_tokens, 50);
1370 assert_eq!(u.total_tokens, 150);
1371 assert_eq!(u.prompt_tokens_details.as_ref().unwrap().cached_tokens, 30);
1372 assert_eq!(
1373 u.prompt_tokens_details.as_ref().unwrap().cache_write_tokens,
1374 Some(10)
1375 );
1376 assert_eq!(
1377 u.completion_tokens_details
1378 .as_ref()
1379 .unwrap()
1380 .reasoning_tokens,
1381 Some(20)
1382 );
1383 }
1384
1385 #[test]
1386 fn test_finish_reason_mapping() {
1387 let cases = [
1388 (FinishReasonUnified::Stop, "stop"),
1389 (FinishReasonUnified::Length, "length"),
1390 (FinishReasonUnified::ContentFilter, "content_filter"),
1391 (FinishReasonUnified::ToolCalls, "tool_calls"),
1392 (FinishReasonUnified::Error, "stop"),
1393 ];
1394 for (unified, expected) in cases {
1395 let fr = FinishReason { unified, raw: None };
1396 assert_eq!(finish_reason_to_openai(&fr).as_deref(), Some(expected));
1397 }
1398 }
1399
1400 async fn collect_stream(stream: ChatCompletionStream) -> Vec<ChatCompletionChunk> {
1403 let mut chunks = Vec::new();
1404 let mut s = stream.stream;
1405 while let Some(result) = s.next().await {
1406 chunks.push(result.unwrap());
1407 }
1408 chunks
1409 }
1410
1411 #[tokio::test]
1412 async fn test_stream_text_only() {
1413 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1414 Ok(StreamPart::StreamStart { warnings: vec![] }),
1415 Ok(StreamPart::TextStart {
1416 id: "0".to_string(),
1417 provider_metadata: None,
1418 }),
1419 Ok(StreamPart::TextDelta {
1420 id: "0".to_string(),
1421 delta: "Hello ".to_string(),
1422 provider_metadata: None,
1423 }),
1424 Ok(StreamPart::TextDelta {
1425 id: "0".to_string(),
1426 delta: "world".to_string(),
1427 provider_metadata: None,
1428 }),
1429 Ok(StreamPart::TextEnd {
1430 id: "0".to_string(),
1431 provider_metadata: None,
1432 }),
1433 Ok(StreamPart::Finish {
1434 finish_reason: FinishReason {
1435 unified: FinishReasonUnified::Stop,
1436 raw: None,
1437 },
1438 usage: Usage::default(),
1439 provider_metadata: None,
1440 }),
1441 ];
1442
1443 let input_stream = Box::pin(futures::stream::iter(parts));
1444 let result =
1445 to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default());
1446 let chunks = collect_stream(result).await;
1447
1448 assert_eq!(
1450 chunks[0].choices[0].delta.role.as_deref(),
1451 Some("assistant")
1452 );
1453 assert_eq!(chunks[0].choices[0].delta.content.as_deref(), Some(""));
1454
1455 assert_eq!(
1457 chunks[1].choices[0].delta.content.as_deref(),
1458 Some("Hello ")
1459 );
1460 assert_eq!(chunks[2].choices[0].delta.content.as_deref(), Some("world"));
1461
1462 let last = chunks.last().unwrap();
1464 assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop"));
1465 assert!(last.choices[0].delta.content.is_none());
1466 }
1467
1468 #[tokio::test]
1469 async fn test_stream_tool_calls() {
1470 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1471 Ok(StreamPart::StreamStart { warnings: vec![] }),
1472 Ok(StreamPart::ToolInputStart {
1473 id: "call_1".to_string(),
1474 tool_name: "get_weather".to_string(),
1475 provider_executed: None,
1476 dynamic: None,
1477 title: None,
1478 provider_metadata: None,
1479 }),
1480 Ok(StreamPart::ToolInputDelta {
1481 id: "call_1".to_string(),
1482 delta: "{\"city\":".to_string(),
1483 provider_metadata: None,
1484 }),
1485 Ok(StreamPart::ToolInputDelta {
1486 id: "call_1".to_string(),
1487 delta: "\"Tokyo\"}".to_string(),
1488 provider_metadata: None,
1489 }),
1490 Ok(StreamPart::ToolInputEnd {
1491 id: "call_1".to_string(),
1492 provider_metadata: None,
1493 }),
1494 Ok(StreamPart::ToolCall {
1495 tool_call_id: "call_1".to_string(),
1496 tool_name: "get_weather".to_string(),
1497 input: json!({ "city": "Tokyo" }),
1498 provider_executed: None,
1499 dynamic: None,
1500 thought_signature: None,
1501 invalid: None,
1502 error: None,
1503 provider_metadata: None,
1504 }),
1505 Ok(StreamPart::Finish {
1506 finish_reason: FinishReason {
1507 unified: FinishReasonUnified::ToolCalls,
1508 raw: None,
1509 },
1510 usage: Usage::default(),
1511 provider_metadata: None,
1512 }),
1513 ];
1514
1515 let input_stream = Box::pin(futures::stream::iter(parts));
1516 let result =
1517 to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default());
1518 let chunks = collect_stream(result).await;
1519
1520 let open_chunk = chunks
1522 .iter()
1523 .find(|c| {
1524 c.choices
1525 .first()
1526 .and_then(|ch| ch.delta.tool_calls.as_ref())
1527 .and_then(|tcs| tcs.first())
1528 .and_then(|tc| tc.id.as_deref())
1529 == Some("call_1")
1530 })
1531 .expect("tool call opening chunk not found");
1532
1533 let tc = open_chunk.choices[0]
1534 .delta
1535 .tool_calls
1536 .as_ref()
1537 .unwrap()
1538 .first()
1539 .unwrap();
1540 assert_eq!(tc.index, 0);
1541 assert_eq!(tc.id.as_deref(), Some("call_1"));
1542 assert_eq!(tc.tool_type.as_deref(), Some("function"));
1543 assert_eq!(tc.function.name.as_deref(), Some("get_weather"));
1544
1545 let arg_chunks: Vec<_> = chunks
1547 .iter()
1548 .filter(|c| {
1549 c.choices
1550 .first()
1551 .and_then(|ch| ch.delta.tool_calls.as_ref())
1552 .and_then(|tcs| tcs.first())
1553 .is_some_and(|tc| tc.id.is_none() && tc.function.arguments.is_some())
1554 })
1555 .collect();
1556 assert_eq!(arg_chunks.len(), 2);
1557 assert_eq!(
1558 arg_chunks[0].choices[0].delta.tool_calls.as_ref().unwrap()[0]
1559 .function
1560 .arguments
1561 .as_deref(),
1562 Some("{\"city\":")
1563 );
1564
1565 let last = chunks.last().unwrap();
1567 assert_eq!(last.choices[0].finish_reason.as_deref(), Some("tool_calls"));
1568 }
1569
1570 #[tokio::test]
1571 async fn test_stream_complete_call_backfills_arguments_absent_from_deltas() {
1572 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1573 Ok(StreamPart::ToolInputStart {
1574 id: "call_1".to_string(),
1575 tool_name: "fetch".to_string(),
1576 provider_executed: Some(true),
1577 dynamic: None,
1578 title: None,
1579 provider_metadata: None,
1580 }),
1581 Ok(StreamPart::ToolInputEnd {
1582 id: "call_1".to_string(),
1583 provider_metadata: None,
1584 }),
1585 Ok(StreamPart::ToolCall {
1586 tool_call_id: "call_1".to_string(),
1587 tool_name: "fetch".to_string(),
1588 input: json!({ "url": "https://example.com" }),
1589 provider_executed: Some(true),
1590 dynamic: None,
1591 thought_signature: None,
1592 invalid: None,
1593 error: None,
1594 provider_metadata: None,
1595 }),
1596 Ok(StreamPart::Finish {
1597 finish_reason: FinishReason {
1598 unified: FinishReasonUnified::Stop,
1599 raw: None,
1600 },
1601 usage: Usage::default(),
1602 provider_metadata: None,
1603 }),
1604 ];
1605
1606 let result = to_chat_completion_stream(
1607 Box::pin(futures::stream::iter(parts)),
1608 "claude",
1609 OpenAiStreamOptions::default(),
1610 );
1611 let chunks = collect_stream(result).await;
1612 let arguments = chunks
1613 .iter()
1614 .filter_map(|chunk| {
1615 chunk
1616 .choices
1617 .first()?
1618 .delta
1619 .tool_calls
1620 .as_ref()?
1621 .first()?
1622 .function
1623 .arguments
1624 .as_deref()
1625 })
1626 .collect::<String>();
1627
1628 assert_eq!(arguments, r#"{"url":"https://example.com"}"#);
1629 }
1630
1631 #[tokio::test]
1632 async fn test_stream_multiple_tool_calls_index() {
1633 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1634 Ok(StreamPart::StreamStart { warnings: vec![] }),
1635 Ok(StreamPart::ToolInputStart {
1636 id: "call_a".to_string(),
1637 tool_name: "tool_a".to_string(),
1638 provider_executed: None,
1639 dynamic: None,
1640 title: None,
1641 provider_metadata: None,
1642 }),
1643 Ok(StreamPart::ToolInputStart {
1644 id: "call_b".to_string(),
1645 tool_name: "tool_b".to_string(),
1646 provider_executed: None,
1647 dynamic: None,
1648 title: None,
1649 provider_metadata: None,
1650 }),
1651 Ok(StreamPart::Finish {
1652 finish_reason: FinishReason {
1653 unified: FinishReasonUnified::ToolCalls,
1654 raw: None,
1655 },
1656 usage: Usage::default(),
1657 provider_metadata: None,
1658 }),
1659 ];
1660
1661 let input_stream = Box::pin(futures::stream::iter(parts));
1662 let result =
1663 to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default());
1664 let chunks = collect_stream(result).await;
1665
1666 let open_chunks: Vec<_> = chunks
1668 .iter()
1669 .filter(|c| {
1670 c.choices
1671 .first()
1672 .and_then(|ch| ch.delta.tool_calls.as_ref())
1673 .and_then(|tcs| tcs.first())
1674 .and_then(|tc| tc.id.as_deref())
1675 .is_some()
1676 })
1677 .collect();
1678 assert_eq!(open_chunks.len(), 2);
1679
1680 let tc0 = &open_chunks[0].choices[0].delta.tool_calls.as_ref().unwrap()[0];
1681 let tc1 = &open_chunks[1].choices[0].delta.tool_calls.as_ref().unwrap()[0];
1682 assert_eq!(tc0.index, 0);
1683 assert_eq!(tc0.id.as_deref(), Some("call_a"));
1684 assert_eq!(tc1.index, 1);
1685 assert_eq!(tc1.id.as_deref(), Some("call_b"));
1686 }
1687
1688 #[tokio::test]
1689 async fn test_stream_reasoning() {
1690 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1691 Ok(StreamPart::StreamStart { warnings: vec![] }),
1692 Ok(StreamPart::ReasoningStart {
1693 id: "reasoning-0".to_string(),
1694 provider_metadata: None,
1695 }),
1696 Ok(StreamPart::ReasoningDelta {
1697 id: "reasoning-0".to_string(),
1698 delta: "Hmm...".to_string(),
1699 provider_metadata: None,
1700 }),
1701 Ok(StreamPart::ReasoningEnd {
1702 id: "reasoning-0".to_string(),
1703 provider_metadata: None,
1704 }),
1705 Ok(StreamPart::TextDelta {
1706 id: "0".to_string(),
1707 delta: "Answer".to_string(),
1708 provider_metadata: None,
1709 }),
1710 Ok(StreamPart::Finish {
1711 finish_reason: FinishReason {
1712 unified: FinishReasonUnified::Stop,
1713 raw: None,
1714 },
1715 usage: Usage::default(),
1716 provider_metadata: None,
1717 }),
1718 ];
1719
1720 let input_stream = Box::pin(futures::stream::iter(parts));
1721 let result =
1722 to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default());
1723 let chunks = collect_stream(result).await;
1724
1725 let reasoning_chunk = chunks
1727 .iter()
1728 .find(|c| c.choices[0].delta.reasoning_content.is_some())
1729 .expect("reasoning chunk not found");
1730 assert_eq!(
1731 reasoning_chunk.choices[0]
1732 .delta
1733 .reasoning_content
1734 .as_deref(),
1735 Some("Hmm...")
1736 );
1737
1738 let text_chunk = chunks
1740 .iter()
1741 .find(|c| {
1742 c.choices[0]
1743 .delta
1744 .content
1745 .as_deref()
1746 .is_some_and(|s| s == "Answer")
1747 })
1748 .expect("text chunk not found");
1749 assert_eq!(
1750 text_chunk.choices[0].delta.content.as_deref(),
1751 Some("Answer")
1752 );
1753 }
1754
1755 #[tokio::test]
1756 async fn test_stream_usage_in_final_chunk() {
1757 let usage = Usage {
1758 input_tokens: TokenUsage {
1759 total: Some(42),
1760 ..Default::default()
1761 },
1762 output_tokens: TokenUsage {
1763 total: Some(10),
1764 ..Default::default()
1765 },
1766 raw: None,
1767 };
1768
1769 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1770 Ok(StreamPart::StreamStart { warnings: vec![] }),
1771 Ok(StreamPart::TextDelta {
1772 id: "0".to_string(),
1773 delta: "Hi".to_string(),
1774 provider_metadata: None,
1775 }),
1776 Ok(StreamPart::Finish {
1777 finish_reason: FinishReason {
1778 unified: FinishReasonUnified::Stop,
1779 raw: None,
1780 },
1781 usage,
1782 provider_metadata: None,
1783 }),
1784 ];
1785
1786 let input_stream = Box::pin(futures::stream::iter(parts));
1787 let result = to_chat_completion_stream(
1788 input_stream,
1789 "gpt-4o",
1790 OpenAiStreamOptions {
1791 include_usage: true,
1792 include_reasoning: true,
1793 },
1794 );
1795 let chunks = collect_stream(result).await;
1796
1797 let last = chunks.last().unwrap();
1798 assert_eq!(last.usage.as_ref().unwrap().prompt_tokens, 42);
1799 assert_eq!(last.usage.as_ref().unwrap().completion_tokens, 10);
1800 assert_eq!(last.usage.as_ref().unwrap().total_tokens, 52);
1801 }
1802
1803 #[tokio::test]
1804 async fn test_stream_no_usage_when_disabled() {
1805 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1806 Ok(StreamPart::StreamStart { warnings: vec![] }),
1807 Ok(StreamPart::Finish {
1808 finish_reason: FinishReason {
1809 unified: FinishReasonUnified::Stop,
1810 raw: None,
1811 },
1812 usage: Usage {
1813 input_tokens: TokenUsage {
1814 total: Some(42),
1815 ..Default::default()
1816 },
1817 output_tokens: TokenUsage::default(),
1818 raw: None,
1819 },
1820 provider_metadata: None,
1821 }),
1822 ];
1823
1824 let input_stream = Box::pin(futures::stream::iter(parts));
1825 let result = to_chat_completion_stream(
1826 input_stream,
1827 "gpt-4o",
1828 OpenAiStreamOptions {
1829 include_usage: false,
1830 include_reasoning: true,
1831 },
1832 );
1833 let chunks = collect_stream(result).await;
1834
1835 let last = chunks.last().unwrap();
1836 assert!(last.usage.is_none());
1837 }
1838
1839 #[tokio::test]
1840 async fn test_stream_error() {
1841 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1842 Ok(StreamPart::StreamStart { warnings: vec![] }),
1843 Ok(StreamPart::TextDelta {
1844 id: "0".to_string(),
1845 delta: "partial".to_string(),
1846 provider_metadata: None,
1847 }),
1848 Ok(StreamPart::Error {
1849 error: AiMuxError::ApiCall(Box::new(crate::error::ApiCallError::new(
1850 "something went wrong",
1851 "https://example.test/v1",
1852 serde_json::json!({}),
1853 ))),
1854 }),
1855 ];
1856
1857 let input_stream = Box::pin(futures::stream::iter(parts));
1858 let result =
1859 to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default());
1860 let chunks = collect_stream(result).await;
1861
1862 let error_chunk = chunks
1864 .iter()
1865 .find(|c| {
1866 c.choices[0]
1867 .delta
1868 .content
1869 .as_deref()
1870 .is_some_and(|s| s.contains("[error]"))
1871 })
1872 .expect("error chunk not found");
1873
1874 assert!(
1875 error_chunk.choices[0]
1876 .delta
1877 .content
1878 .as_deref()
1879 .unwrap()
1880 .contains("something went wrong")
1881 );
1882
1883 let last = chunks.last().unwrap();
1885 assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop"));
1886 }
1887
1888 #[tokio::test]
1889 async fn recoverable_frame_error_does_not_truncate_chat_completion_stream() {
1890 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1891 Ok(StreamPart::StreamStart { warnings: vec![] }),
1892 Err(AiMuxError::JsonParse("malformed SSE data".into())),
1893 Ok(StreamPart::TextDelta {
1894 id: "0".to_string(),
1895 delta: "after-error".to_string(),
1896 provider_metadata: None,
1897 }),
1898 Ok(StreamPart::Finish {
1899 finish_reason: FinishReason {
1900 unified: FinishReasonUnified::Stop,
1901 raw: Some("stop".into()),
1902 },
1903 usage: Usage::default(),
1904 provider_metadata: None,
1905 }),
1906 ];
1907
1908 let result = to_chat_completion_stream(
1909 Box::pin(futures::stream::iter(parts)),
1910 "gpt-4o",
1911 OpenAiStreamOptions::default(),
1912 );
1913 let outcomes: Vec<_> = result.stream.collect().await;
1914 let error_index = outcomes
1915 .iter()
1916 .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_))))
1917 .expect("parse error should be preserved as an error item");
1918
1919 assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!(
1920 outcome,
1921 Ok(chunk)
1922 if chunk.choices.first().and_then(|choice| choice.delta.content.as_deref())
1923 == Some("after-error")
1924 )));
1925 assert!(outcomes.iter().any(|outcome| matches!(
1926 outcome,
1927 Ok(chunk)
1928 if chunk.choices.first().and_then(|choice| choice.finish_reason.as_deref())
1929 == Some("stop")
1930 )));
1931 }
1932
1933 #[tokio::test]
1934 async fn test_stream_completes_without_finish() {
1935 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
1937 Ok(StreamPart::StreamStart { warnings: vec![] }),
1938 Ok(StreamPart::TextDelta {
1939 id: "0".to_string(),
1940 delta: "Hello".to_string(),
1941 provider_metadata: None,
1942 }),
1943 ];
1944
1945 let input_stream = Box::pin(futures::stream::iter(parts));
1946 let result =
1947 to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default());
1948 let chunks = collect_stream(result).await;
1949
1950 let last = chunks.last().unwrap();
1951 assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop"));
1952 }
1953
1954 #[test]
1955 fn test_sse_encoding() {
1956 let chunk = ChatCompletionChunk {
1957 id: "chatcmpl-test".to_string(),
1958 object: "chat.completion.chunk".to_string(),
1959 created: 1234567890,
1960 model: "gpt-4o".to_string(),
1961 choices: vec![ChatCompletionChunkChoice {
1962 index: 0,
1963 delta: ChatCompletionDelta {
1964 content: Some("Hi".to_string()),
1965 ..Default::default()
1966 },
1967 finish_reason: None,
1968 logprobs: None,
1969 }],
1970 usage: None,
1971 };
1972
1973 let sse = encode_chunk_sse(&chunk);
1974 assert!(sse.starts_with("data: {"));
1975 assert!(sse.ends_with("\n\n"));
1976 assert!(sse.contains("\"content\":\"Hi\""));
1977 }
1978
1979 #[test]
1980 fn test_done_frame() {
1981 assert_eq!(DONE_FRAME, "data: [DONE]\n\n");
1982 }
1983}