Skip to main content

aimux_core/
openai_output.rs

1//! OpenAI Chat Completions output format.
2//!
3//! Converts aimux's internal types ([`GenerateResult`] / [`StreamPart`]) into
4//! standard OpenAI Chat Completions structures ([`ChatCompletion`] /
5//! [`ChatCompletionChunk`]). This lets any provider (OpenAI, Anthropic, Google,
6//! …) be consumed via the OpenAI wire format.
7//!
8//! # Architecture
9//!
10//! ```text
11//!   provider.do_generate()  →  GenerateResult  →  to_chat_completion()  →  ChatCompletion
12//!   provider.do_stream()    →  Stream<StreamPart>  →  to_chat_completion_stream()  →  Stream<Chunk>
13//! ```
14//!
15//! The conversion is a post-processing step — it does not modify the
16//! `LanguageModel` trait or existing `generate_text` / `stream_text` APIs.
17//!
18//! # Round-trip fidelity
19//!
20//! For OpenAI-compatible providers the path is:
21//! `OpenAI JSON → GenerateResult → ChatCompletion`. The content and tool_calls
22//! fields round-trip losslessly: arguments go through `from_str ↔ to_string`
23//! (a reversible pair), and usage fields map back to their OpenAI names.
24//! Response metadata (`object`, `created`, `system_fingerprint`) is either
25//! reconstructed from constants or taken from `GenerateResult.response`.
26
27use 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// ─────────────────────────────────────────────────────────────────────────────
45// Non-streaming response types
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// A complete Chat Completion response (non-streaming).
49///
50/// Mirrors the OpenAI `chat.completion` object.
51#[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// ─────────────────────────────────────────────────────────────────────────────
104// Streaming response types
105// ─────────────────────────────────────────────────────────────────────────────
106
107/// A single Chat Completion chunk (streaming).
108///
109/// Mirrors the OpenAI `chat.completion.chunk` object.
110#[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// ─────────────────────────────────────────────────────────────────────────────
166// Usage (shared by streaming and non-streaming)
167// ─────────────────────────────────────────────────────────────────────────────
168
169#[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// ─────────────────────────────────────────────────────────────────────────────
197// Options
198// ─────────────────────────────────────────────────────────────────────────────
199
200/// Options for streaming OpenAI-compatible output.
201#[derive(Debug, Clone)]
202pub struct OpenAiStreamOptions {
203    /// Whether to include `usage` in the final chunk
204    /// (corresponds to `stream_options.include_usage`).
205    pub include_usage: bool,
206    /// Whether to emit `reasoning_content` deltas (default `true`).
207    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// ─────────────────────────────────────────────────────────────────────────────
220// Non-streaming conversion: GenerateResult → ChatCompletion
221// ─────────────────────────────────────────────────────────────────────────────
222
223/// Convert a [`GenerateResult`] into an OpenAI [`ChatCompletion`].
224///
225/// `model` is the model ID to place in the response (usually the model the
226/// caller invoked). If `result.response.model_id` is present it takes
227/// precedence.
228#[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                // The provider's raw argument text passes through verbatim;
250                // OpenAI's wire format requires a JSON object even when the
251                // model emitted no arguments at all.
252                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                // Map to OpenAI url_citation annotation.
268                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                // Also append URL to content so non-annotation-aware clients see it.
279                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                // Degraded mapping: provider-executed tool result as text.
301                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    // content: null when there are tool_calls and no text, else the string.
316    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    // finish_reason
343    let finish_reason = finish_reason_to_openai(&result.finish_reason);
344
345    // logprobs from provider_metadata
346    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    // id / model / created
354    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
382// ─────────────────────────────────────────────────────────────────────────────
383// Streaming conversion: Stream<StreamPart> → Stream<ChatCompletionChunk>
384// ─────────────────────────────────────────────────────────────────────────────
385
386/// A streaming OpenAI Chat Completions result.
387pub struct ChatCompletionStream {
388    /// The stream of `ChatCompletionChunk` items.
389    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/// Convert a `StreamPart` stream into a `ChatCompletionChunk` stream.
401///
402/// Uses a stateful converter that maintains tool-call indices and accumulates
403/// the final usage / finish_reason. The output follows OpenAI SSE conventions:
404/// the first chunk carries `delta.role = "assistant"`, content/reasoning/tool
405/// deltas follow, and the final chunk carries `finish_reason` (and `usage` if
406/// `include_usage` is set).
407///
408/// Tool-input deltas are forwarded as the provider sends them, as in the AI
409/// SDK; they are never held back. See `stream_text_as_openai`.
410#[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                    // Emit error as a content delta + finish, then stop.
435                    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            // After Finish, stop (but we already emitted the final chunk).
451            if matches!(part, StreamPart::Finish { .. }) {
452                return;
453            }
454        }
455
456        // Stream ended without Finish — emit a final chunk.
457        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
467/// Internal state for the streaming converter.
468struct StreamState {
469    id: String,
470    model: String,
471    created: u64,
472    started: bool,
473    /// Tool-call accumulators keyed by tool_call_id.
474    tool_calls: HashMap<String, ToolCallAccum>,
475    tool_call_order: Vec<String>,
476    next_tool_index: u32,
477    /// Whether each tool_call_id has had its opening chunk emitted.
478    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    /// Argument bytes already emitted through OpenAI delta chunks.
490    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    /// Build a base chunk with id/model/created.
511    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    /// Ensure the role-frame has been sent; return it if newly created.
523    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    /// Process a single StreamPart, returning zero or more output chunks.
543    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                // Assign index.
616                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                // Ensure started (shouldn't happen without Start, but be safe).
657                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                        // Delta without Start — allocate a new index.
667                        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                // Complete tool call (e.g. from non-streaming-style providers).
714                // A provider may carry all input on its start frame and emit no
715                // deltas. In that case the final call is the first point where
716                // the OpenAI adapter can forward those arguments.
717                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                // Degraded: provider-executed tool result as content.
798                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                // Emit the final chunk here.
863                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 { .. } => { /* ignore */ }
875        }
876
877        chunks
878    }
879
880    /// Build an error content chunk.
881    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    /// Build the final finish chunk (called when the stream ends or on Finish).
900    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// ─────────────────────────────────────────────────────────────────────────────
941// SSE encoding helpers
942// ─────────────────────────────────────────────────────────────────────────────
943
944/// Encode a [`ChatCompletionChunk`] as an SSE `data:` line: `data: {json}\n\n`.
945#[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
951/// The SSE terminator frame.
952pub const DONE_FRAME: &str = "data: [DONE]\n\n";
953
954// ─────────────────────────────────────────────────────────────────────────────
955// Internal helpers
956// ─────────────────────────────────────────────────────────────────────────────
957
958/// Map an aimux [`FinishReason`] to an OpenAI `finish_reason` string.
959fn 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
970/// Convert aimux [`Usage`] to OpenAI [`ChatCompletionUsage`].
971///
972/// This is the inverse of `convert_usage` in
973/// `aimux-providers/src/openai/model.rs`.
974fn 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
994/// Convert [`FileData`] to a text representation (degraded — placed in content).
995fn 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
1013/// Current Unix timestamp.
1014fn now_unix() -> u64 {
1015    SystemTime::now()
1016        .duration_since(UNIX_EPOCH)
1017        .map(|d| d.as_secs())
1018        .unwrap_or(0)
1019}
1020
1021// Rejected repairs retain the original call. Recover its text from the
1022// original error, never from the replacement's validation failure.
1023/// Render a Core-parsed `StreamPart::ToolCall`'s arguments as OpenAI-compatible
1024/// wire text: the provider's raw argument text verbatim for an invalid call,
1025/// compact JSON of the parsed value otherwise.
1026///
1027/// Older errors without raw argument text fall back to serializing the
1028/// parsed input as JSON.
1029pub(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        // Blank text still fails validation against a schema with required
1039        // properties, but OpenAI's wire format has no representation for
1040        // "no arguments" other than an empty object — same rule as
1041        // `to_chat_completion` applies to the unparsed non-streaming path.
1042        return if raw.trim().is_empty() {
1043            "{}".to_string()
1044        } else {
1045            raw
1046        };
1047    }
1048    // `Value` Display is compact JSON, the `JSON.stringify` equivalent —
1049    // correct for every shape, `null` included.
1050    input.to_string()
1051}
1052
1053/// Generate a short random ID (24 hex chars, similar to OpenAI's chatcmpl IDs).
1054fn random_id() -> String {
1055    // Use a simple counter + timestamp for deterministic-enough uniqueness.
1056    // This is not cryptographically random — it only needs to be unique within
1057    // a process for response identification.
1058    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// ─────────────────────────────────────────────────────────────────────────────
1066// Tests
1067// ─────────────────────────────────────────────────────────────────────────────
1068
1069#[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        // Set finish_reason to ToolCalls.
1139        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        // Both OpenAI renderers: blank raw text has no wire representation
1185        // other than `{}`.
1186        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        // A valid call whose parsed input happens to be `null` (no schema
1218        // constraint rejected it) must round-trip as `null`, not `{}`.
1219        let arguments = parsed_tool_call_arguments(&Value::Null, None, None);
1220        assert_eq!(arguments, "null");
1221    }
1222
1223    /// Regression coverage for the P1 finding on PR #165: `input: Value`
1224    /// alone cannot distinguish a validly parsed JSON string from malformed
1225    /// text wrapped as a fallback string, so an invalid call's arguments are
1226    /// recovered from its typed error instead. Driven end to end so the
1227    /// error/`input` pairing `parse_tool_call` actually produces is pinned
1228    /// too, not just the renderer.
1229    #[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            // Valid JSON string rejected by the schema: the quotes must
1236            // survive, or the arguments field stops being JSON at all.
1237            ("get_weather", r#""hello""#, None),
1238            // Unknown tool *and* malformed text: `NoSuchTool` carries no raw
1239            // text of its own, so it comes off the unparsable `input`.
1240            ("unknown_tool", r#"{"a":"#, None),
1241            // A failed repair callback wraps the pre-repair error.
1242            ("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        // Tool call with no text → content should be null.
1307        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    // ── Streaming tests ──
1401
1402    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        // First chunk: role frame.
1449        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        // Content deltas.
1456        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        // Final chunk: finish_reason.
1463        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        // Find the tool_call opening chunk (with id + name).
1521        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        // Find argument delta chunks.
1546        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        // Final chunk.
1566        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        // Find both tool call opening chunks.
1667        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        // Find reasoning chunk.
1726        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        // Find text chunk.
1739        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        // Error should appear as a content delta.
1863        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        // Final chunk should still have finish_reason.
1884        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        // Stream ends without Finish — should still emit a final chunk.
1936        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}