Skip to main content

rig_core/providers/openai/completion/
mod.rs

1// ================================================================
2// OpenAI Completion API
3// ================================================================
4
5use super::client::ApiResponse;
6use crate::completion::NormalizeCompletionResponse;
7use crate::completion::{CompletionError, CompletionRequest as CoreCompletionRequest};
8use crate::http_client::HttpClientExt;
9use crate::json_utils::string_or_vec;
10use crate::message::{AudioMediaType, DocumentSourceKind, ImageDetail, MimeType};
11use crate::providers::internal::completion_send::send_completion;
12use crate::telemetry::{
13    CompletionOperation, CompletionSpanBuilder, ProviderResponseExt, SpanCombinator,
14};
15use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
16use crate::{completion, json_utils, message};
17use serde::{Deserialize, Serialize, Serializer};
18use std::convert::Infallible;
19use std::fmt;
20use tracing::Instrument;
21
22use std::str::FromStr;
23
24pub mod streaming;
25
26/// Serializes user content as a plain string when there's a single text item,
27/// otherwise as an array of content parts.
28fn serialize_user_content<S>(content: &[UserContent], serializer: S) -> Result<S::Ok, S::Error>
29where
30    S: Serializer,
31{
32    if content.len() == 1
33        && let Some(UserContent::Text { text, .. }) = content.first()
34    {
35        return serializer.serialize_str(text);
36    }
37    content.serialize(serializer)
38}
39
40/// `gpt-5.6` completion model (alias that routes to GPT-5.6 Sol)
41pub const GPT_5_6: &str = "gpt-5.6";
42
43/// `gpt-5.6-sol` completion model
44pub const GPT_5_6_SOL: &str = "gpt-5.6-sol";
45
46/// `gpt-5.6-terra` completion model
47pub const GPT_5_6_TERRA: &str = "gpt-5.6-terra";
48
49/// `gpt-5.6-luna` completion model
50pub const GPT_5_6_LUNA: &str = "gpt-5.6-luna";
51
52/// `gpt-5.5` completion model
53pub const GPT_5_5: &str = "gpt-5.5";
54
55/// `gpt-5.2` completion model
56pub const GPT_5_2: &str = "gpt-5.2";
57
58/// `gpt-5.1` completion model
59pub const GPT_5_1: &str = "gpt-5.1";
60
61/// `gpt-5` completion model
62pub const GPT_5: &str = "gpt-5";
63/// `gpt-5` completion model
64pub const GPT_5_MINI: &str = "gpt-5-mini";
65/// `gpt-5` completion model
66pub const GPT_5_NANO: &str = "gpt-5-nano";
67
68/// `gpt-4.5-preview` completion model
69pub const GPT_4_5_PREVIEW: &str = "gpt-4.5-preview";
70/// `gpt-4.5-preview-2025-02-27` completion model
71pub const GPT_4_5_PREVIEW_2025_02_27: &str = "gpt-4.5-preview-2025-02-27";
72/// `gpt-4o-2024-11-20` completion model (this is newer than 4o)
73pub const GPT_4O_2024_11_20: &str = "gpt-4o-2024-11-20";
74/// `gpt-4o` completion model
75pub const GPT_4O: &str = "gpt-4o";
76/// `gpt-4o-mini` completion model
77pub const GPT_4O_MINI: &str = "gpt-4o-mini";
78/// `gpt-4o-2024-05-13` completion model
79pub const GPT_4O_2024_05_13: &str = "gpt-4o-2024-05-13";
80/// `gpt-4-turbo` completion model
81pub const GPT_4_TURBO: &str = "gpt-4-turbo";
82/// `gpt-4-turbo-2024-04-09` completion model
83pub const GPT_4_TURBO_2024_04_09: &str = "gpt-4-turbo-2024-04-09";
84/// `gpt-4-turbo-preview` completion model
85pub const GPT_4_TURBO_PREVIEW: &str = "gpt-4-turbo-preview";
86/// `gpt-4-0125-preview` completion model
87pub const GPT_4_0125_PREVIEW: &str = "gpt-4-0125-preview";
88/// `gpt-4-1106-preview` completion model
89pub const GPT_4_1106_PREVIEW: &str = "gpt-4-1106-preview";
90/// `gpt-4-vision-preview` completion model
91pub const GPT_4_VISION_PREVIEW: &str = "gpt-4-vision-preview";
92/// `gpt-4-1106-vision-preview` completion model
93pub const GPT_4_1106_VISION_PREVIEW: &str = "gpt-4-1106-vision-preview";
94/// `gpt-4` completion model
95pub const GPT_4: &str = "gpt-4";
96/// `gpt-4-0613` completion model
97pub const GPT_4_0613: &str = "gpt-4-0613";
98/// `gpt-4-32k` completion model
99pub const GPT_4_32K: &str = "gpt-4-32k";
100/// `gpt-4-32k-0613` completion model
101pub const GPT_4_32K_0613: &str = "gpt-4-32k-0613";
102
103/// `o4-mini-2025-04-16` completion model
104pub const O4_MINI_2025_04_16: &str = "o4-mini-2025-04-16";
105/// `o4-mini` completion model
106pub const O4_MINI: &str = "o4-mini";
107/// `o3` completion model
108pub const O3: &str = "o3";
109/// `o3-mini` completion model
110pub const O3_MINI: &str = "o3-mini";
111/// `o3-mini-2025-01-31` completion model
112pub const O3_MINI_2025_01_31: &str = "o3-mini-2025-01-31";
113/// `o1-pro` completion model
114pub const O1_PRO: &str = "o1-pro";
115/// `o1`` completion model
116pub const O1: &str = "o1";
117/// `o1-2024-12-17` completion model
118pub const O1_2024_12_17: &str = "o1-2024-12-17";
119/// `o1-preview` completion model
120pub const O1_PREVIEW: &str = "o1-preview";
121/// `o1-preview-2024-09-12` completion model
122pub const O1_PREVIEW_2024_09_12: &str = "o1-preview-2024-09-12";
123/// `o1-mini completion model
124pub const O1_MINI: &str = "o1-mini";
125/// `o1-mini-2024-09-12` completion model
126pub const O1_MINI_2024_09_12: &str = "o1-mini-2024-09-12";
127
128/// `gpt-4.1-mini` completion model
129pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
130/// `gpt-4.1-nano` completion model
131pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
132/// `gpt-4.1-2025-04-14` completion model
133pub const GPT_4_1_2025_04_14: &str = "gpt-4.1-2025-04-14";
134/// `gpt-4.1` completion model
135pub const GPT_4_1: &str = "gpt-4.1";
136
137#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
138#[serde(tag = "role", rename_all = "lowercase")]
139pub enum Message {
140    #[serde(alias = "developer")]
141    System {
142        #[serde(deserialize_with = "string_or_vec")]
143        content: Vec<SystemContent>,
144        #[serde(skip_serializing_if = "Option::is_none")]
145        name: Option<String>,
146    },
147    User {
148        #[serde(
149            deserialize_with = "string_or_vec",
150            serialize_with = "serialize_user_content"
151        )]
152        content: Vec<UserContent>,
153        #[serde(skip_serializing_if = "Option::is_none")]
154        name: Option<String>,
155    },
156    // Gemini-backed OpenAI-compatible gateways (e.g. OpenRouter) can answer
157    // with `role: "model"`; accept it on deserialization.
158    #[serde(alias = "model")]
159    Assistant {
160        #[serde(
161            default,
162            deserialize_with = "json_utils::string_or_vec",
163            skip_serializing_if = "Vec::is_empty",
164            serialize_with = "serialize_assistant_content_vec"
165        )]
166        content: Vec<AssistantContent>,
167        // OpenAI-compatible providers expose hidden reasoning on this non-standard
168        // field, and some require it to be echoed back on assistant tool-call turns.
169        // Serialized as `reasoning_content` (llama.cpp/DeepSeek dialect); the
170        // `reasoning` alias accepts OpenRouter responses.
171        #[serde(
172            skip_serializing_if = "Option::is_none",
173            rename = "reasoning_content",
174            alias = "reasoning"
175        )]
176        reasoning: Option<String>,
177        #[serde(skip_serializing_if = "Option::is_none")]
178        refusal: Option<String>,
179        #[serde(skip_serializing_if = "Option::is_none")]
180        audio: Option<AudioAssistant>,
181        #[serde(skip_serializing_if = "Option::is_none")]
182        name: Option<String>,
183        #[serde(
184            default,
185            deserialize_with = "json_utils::null_or_default",
186            skip_serializing_if = "Vec::is_empty"
187        )]
188        tool_calls: Vec<ToolCall>,
189        /// Structured reasoning blocks used by OpenAI-compatible providers
190        /// such as OpenRouter. Empty (and omitted from the wire) for
191        /// providers that do not emit or accept them.
192        #[serde(default, skip_serializing_if = "Vec::is_empty")]
193        reasoning_details: Vec<ReasoningDetails>,
194        /// Generated images returned by image-generation models (OpenRouter's
195        /// sibling `images` array). Inbound only — never serialized back into
196        /// a request.
197        #[serde(default, skip_serializing)]
198        images: Vec<ResponseImage>,
199    },
200    #[serde(rename = "tool")]
201    ToolResult {
202        tool_call_id: String,
203        content: ToolResultContentValue,
204    },
205}
206
207impl Message {
208    pub fn system(content: &str) -> Self {
209        Message::System {
210            content: vec![content.to_owned().into()],
211            name: None,
212        }
213    }
214}
215
216fn history_contains_tool_result(messages: &[Message]) -> bool {
217    messages
218        .iter()
219        .any(|message| matches!(message, Message::ToolResult { .. }))
220}
221
222#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
223pub struct AudioAssistant {
224    pub id: String,
225}
226
227/// Structured reasoning blocks attached to assistant messages by
228/// OpenAI-compatible providers such as OpenRouter (`reasoning_details`).
229///
230/// The `Option` fields are intentionally serialized even when `None`
231/// (`"format":null,"id":null`) to match the provider wire format.
232#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
233#[serde(tag = "type", rename_all = "snake_case")]
234pub enum ReasoningDetails {
235    #[serde(rename = "reasoning.summary")]
236    Summary {
237        id: Option<String>,
238        format: Option<String>,
239        index: Option<usize>,
240        summary: String,
241    },
242    #[serde(rename = "reasoning.encrypted")]
243    Encrypted {
244        id: Option<String>,
245        format: Option<String>,
246        index: Option<usize>,
247        data: String,
248    },
249    #[serde(rename = "reasoning.text")]
250    Text {
251        id: Option<String>,
252        format: Option<String>,
253        index: Option<usize>,
254        text: Option<String>,
255        signature: Option<String>,
256    },
257}
258
259/// An image emitted by an image-generation model. OpenRouter returns generated
260/// images out-of-band from `content`, as a sibling `images` array on the
261/// assistant message. Each entry mirrors the request-side `image_url` content
262/// part structure.
263#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
264pub struct ResponseImage {
265    pub image_url: ImageUrl,
266}
267
268#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
269pub struct SystemContent {
270    #[serde(default)]
271    pub r#type: SystemContentType,
272    pub text: String,
273}
274
275#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
276#[serde(rename_all = "lowercase")]
277pub enum SystemContentType {
278    #[default]
279    Text,
280}
281
282#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
283#[serde(tag = "type", rename_all = "lowercase")]
284pub enum AssistantContent {
285    Text { text: String },
286    Refusal { refusal: String },
287}
288
289impl From<AssistantContent> for completion::AssistantContent {
290    fn from(value: AssistantContent) -> Self {
291        match value {
292            AssistantContent::Text { text, .. } => completion::AssistantContent::text(text),
293            AssistantContent::Refusal { refusal } => completion::AssistantContent::text(refusal),
294        }
295    }
296}
297
298#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
299#[serde(tag = "type", rename_all = "lowercase")]
300pub enum UserContent {
301    Text {
302        text: String,
303    },
304    #[serde(rename = "image_url")]
305    Image {
306        image_url: ImageUrl,
307    },
308    /// Audio content part. Serialized with OpenAI's `input_audio` wire tag;
309    /// the legacy `audio` tag is still accepted on deserialization.
310    #[serde(rename = "input_audio", alias = "audio")]
311    Audio {
312        input_audio: InputAudio,
313    },
314    /// File content part for documents such as PDFs.
315    ///
316    /// Maps to OpenAI's `{"type":"file","file":{...}}` content type. Either
317    /// `file_data` (a base64 data URI like `data:application/pdf;base64,...`)
318    /// or `file_id` (a previously uploaded file reference) must be set.
319    File {
320        file: FileData,
321    },
322    /// Video content part (URL or base64 data URI), used by OpenAI-compatible
323    /// providers such as OpenRouter. Wire tag: `video_url`.
324    #[serde(rename = "video_url")]
325    Video {
326        video_url: VideoUrl,
327    },
328}
329
330#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
331pub struct ImageUrl {
332    pub url: String,
333    /// Image detail level. Optional so that providers whose wire format omits
334    /// it (e.g. OpenRouter) can leave the key out entirely.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub detail: Option<ImageDetail>,
337}
338
339/// Video payload for [`UserContent::Video`].
340///
341/// `url` is either a publicly accessible URL or a base64 data URI
342/// (e.g. `data:video/mp4;base64,...`).
343#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
344pub struct VideoUrl {
345    pub url: String,
346}
347
348#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
349pub struct InputAudio {
350    pub data: String,
351    pub format: AudioMediaType,
352}
353
354/// File payload for [`UserContent::File`].
355///
356/// At least one of `file_data` or `file_id` must be set for the content part
357/// to be accepted by OpenAI's chat completions API. `filename` is optional
358/// but recommended.
359#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
360pub struct FileData {
361    /// Inline file data as a base64 data URI, e.g.
362    /// `data:application/pdf;base64,JVBERi0xLjQK...`.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub file_data: Option<String>,
365    /// Identifier of a previously uploaded file (OpenAI Files API).
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub file_id: Option<String>,
368    /// Display name of the file. Recommended for inline `file_data`.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub filename: Option<String>,
371}
372
373#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
374pub struct ToolResultContent {
375    #[serde(default)]
376    r#type: ToolResultContentType,
377    pub text: String,
378}
379
380#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
381#[serde(rename_all = "lowercase")]
382pub enum ToolResultContentType {
383    #[default]
384    Text,
385}
386
387impl FromStr for ToolResultContent {
388    type Err = Infallible;
389
390    fn from_str(s: &str) -> Result<Self, Self::Err> {
391        Ok(s.to_owned().into())
392    }
393}
394
395impl From<String> for ToolResultContent {
396    fn from(s: String) -> Self {
397        ToolResultContent {
398            r#type: ToolResultContentType::default(),
399            text: s,
400        }
401    }
402}
403
404#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
405#[serde(untagged)]
406pub enum ToolResultContentValue {
407    Array(Vec<ToolResultContent>),
408    String(String),
409}
410
411impl ToolResultContentValue {
412    pub fn from_string(s: String, use_array_format: bool) -> Self {
413        if use_array_format {
414            ToolResultContentValue::Array(vec![ToolResultContent::from(s)])
415        } else {
416            ToolResultContentValue::String(s)
417        }
418    }
419
420    pub fn as_text(&self) -> String {
421        match self {
422            ToolResultContentValue::Array(arr) => arr
423                .iter()
424                .map(|c| c.text.clone())
425                .collect::<Vec<_>>()
426                .join("\n"),
427            ToolResultContentValue::String(s) => s.clone(),
428        }
429    }
430
431    pub fn to_array(&self) -> Self {
432        match self {
433            ToolResultContentValue::Array(_) => self.clone(),
434            ToolResultContentValue::String(s) => {
435                ToolResultContentValue::Array(vec![ToolResultContent::from(s.clone())])
436            }
437        }
438    }
439}
440
441#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
442pub struct ToolCall {
443    pub id: String,
444    #[serde(default)]
445    pub r#type: ToolType,
446    pub function: Function,
447}
448
449#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
450#[serde(rename_all = "lowercase")]
451pub enum ToolType {
452    #[default]
453    Function,
454}
455
456/// Function definition for a tool, with optional strict mode
457#[derive(Debug, Deserialize, Serialize, Clone)]
458pub struct FunctionDefinition {
459    pub name: String,
460    pub description: String,
461    pub parameters: serde_json::Value,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub strict: Option<bool>,
464}
465
466#[derive(Debug, Deserialize, Serialize, Clone)]
467pub struct ToolDefinition {
468    pub r#type: String,
469    pub function: FunctionDefinition,
470}
471
472impl From<completion::ToolDefinition> for ToolDefinition {
473    fn from(tool: completion::ToolDefinition) -> Self {
474        Self {
475            r#type: "function".into(),
476            function: FunctionDefinition {
477                name: tool.name,
478                description: tool.description,
479                parameters: tool.parameters,
480                strict: None,
481            },
482        }
483    }
484}
485
486impl ToolDefinition {
487    /// Apply strict mode to this tool definition.
488    /// This sets `strict: true` and sanitizes the schema to meet OpenAI requirements.
489    pub fn with_strict(mut self) -> Self {
490        self.function.strict = Some(true);
491        super::sanitize_schema(&mut self.function.parameters);
492        self
493    }
494}
495
496#[derive(Default, Clone, Debug, PartialEq)]
497pub enum ToolChoice {
498    #[default]
499    Auto,
500    None,
501    Required,
502    /// Force the model to call one specific function:
503    /// `{"type": "function", "function": {"name": "..."}}`.
504    Function {
505        name: String,
506    },
507}
508
509#[derive(Deserialize, Serialize)]
510struct ToolChoiceFunctionName {
511    name: String,
512}
513
514#[derive(Deserialize, Serialize)]
515#[serde(tag = "type", rename_all = "snake_case")]
516enum ToolChoiceFunctionRepr {
517    Function { function: ToolChoiceFunctionName },
518}
519
520impl Serialize for ToolChoice {
521    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
522        match self {
523            Self::Auto => serializer.serialize_str("auto"),
524            Self::None => serializer.serialize_str("none"),
525            Self::Required => serializer.serialize_str("required"),
526            Self::Function { name } => ToolChoiceFunctionRepr::Function {
527                function: ToolChoiceFunctionName { name: name.clone() },
528            }
529            .serialize(serializer),
530        }
531    }
532}
533
534impl<'de> Deserialize<'de> for ToolChoice {
535    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
536        #[derive(Deserialize)]
537        #[serde(untagged)]
538        enum Repr {
539            Mode(String),
540            Function(ToolChoiceFunctionRepr),
541        }
542
543        match Repr::deserialize(deserializer)? {
544            Repr::Mode(mode) => match mode.as_str() {
545                "auto" => Ok(Self::Auto),
546                "none" => Ok(Self::None),
547                "required" => Ok(Self::Required),
548                other => Err(serde::de::Error::custom(format!(
549                    "unknown tool_choice mode {other:?}"
550                ))),
551            },
552            Repr::Function(ToolChoiceFunctionRepr::Function {
553                function: ToolChoiceFunctionName { name },
554            }) => Ok(Self::Function { name }),
555        }
556    }
557}
558
559impl ToolChoice {
560    /// Force a call to the named function.
561    pub fn function(name: impl Into<String>) -> Self {
562        Self::Function { name: name.into() }
563    }
564}
565
566impl TryFrom<crate::message::ToolChoice> for ToolChoice {
567    type Error = CompletionError;
568    fn try_from(value: crate::message::ToolChoice) -> Result<Self, Self::Error> {
569        let res = match value {
570            message::ToolChoice::Specific { function_names } => {
571                let [name] = function_names.as_slice() else {
572                    return Err(CompletionError::ProviderError(
573                        "Provider only supports forcing exactly one specific tool".to_string(),
574                    ));
575                };
576                Self::function(name)
577            }
578            message::ToolChoice::Auto => Self::Auto,
579            message::ToolChoice::None => Self::None,
580            message::ToolChoice::Required => Self::Required,
581        };
582
583        Ok(res)
584    }
585}
586
587#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
588pub struct Function {
589    pub name: String,
590    #[serde(
591        serialize_with = "json_utils::stringified_json::serialize",
592        deserialize_with = "json_utils::stringified_json::deserialize_maybe_stringified"
593    )]
594    pub arguments: serde_json::Value,
595}
596
597impl TryFrom<message::ToolResult> for Message {
598    type Error = message::MessageError;
599
600    fn try_from(value: message::ToolResult) -> Result<Self, Self::Error> {
601        // The wire requires a non-empty correlator: the provider-issued
602        // call id when one exists, else rig's minted handle — which is
603        // unique and non-empty by construction, unlike the old empty-
604        // string sentinel.
605        let tool_call_id = value.wire_call_id().to_owned();
606        let parts = value
607            .content
608            .into_iter()
609            .map(|content| match content {
610                message::ToolResultContent::Text(message::Text { text, .. }) => Ok(ToolResultContent::from(text)),
611                message::ToolResultContent::Json { value } => Ok(ToolResultContent::from(value.to_string())),
612                message::ToolResultContent::Image(_) => Err(message::MessageError::ConversionError(
613                    "OpenAI Chat Completions does not support images in tool results. Tool results must be text."
614                        .into(),
615                )),
616            })
617            .collect::<Result<Vec<_>, _>>()?;
618
619        let content = match parts.as_slice() {
620            [part] => ToolResultContentValue::String(part.text.clone()),
621            _ => ToolResultContentValue::Array(parts),
622        };
623
624        Ok(Message::ToolResult {
625            tool_call_id,
626            content,
627        })
628    }
629}
630
631impl TryFrom<message::UserContent> for UserContent {
632    type Error = message::MessageError;
633
634    fn try_from(value: message::UserContent) -> Result<Self, Self::Error> {
635        match value {
636            message::UserContent::Text(message::Text { text, .. }) => Ok(UserContent::Text { text }),
637            message::UserContent::Image(message::Image {
638                data,
639                detail,
640                media_type,
641                ..
642            }) => match data {
643                DocumentSourceKind::Url(url) => Ok(UserContent::Image {
644                    image_url: ImageUrl {
645                        url,
646                        // OpenAI's wire format always carries a detail level;
647                        // absent rig-level detail maps to the default (auto).
648                        detail: Some(detail.unwrap_or_default()),
649                    },
650                }),
651                DocumentSourceKind::Base64(data) => {
652                    let url = format!(
653                        "data:{};base64,{}",
654                        media_type.map(|i| i.to_mime_type()).ok_or(
655                            message::MessageError::ConversionError(
656                                "OpenAI Image URI must have media type".into()
657                            )
658                        )?,
659                        data
660                    );
661
662                    let detail = Some(detail.unwrap_or_default());
663
664                    Ok(UserContent::Image {
665                        image_url: ImageUrl { url, detail },
666                    })
667                }
668                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
669                    "Raw files not supported, encode as base64 first".into(),
670                )),
671                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
672                    "File IDs are not supported for images".into(),
673                )),
674                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
675                    "Document has no body".into(),
676                )),
677                doc => Err(message::MessageError::ConversionError(format!(
678                    "Unsupported document type: {doc:?}"
679                ))),
680            },
681            message::UserContent::Document(message::Document {
682                data: DocumentSourceKind::FileId(file_id),
683                ..
684            }) => Ok(UserContent::File {
685                file: FileData {
686                    file_data: None,
687                    file_id: Some(file_id),
688                    filename: None,
689                },
690            }),
691            message::UserContent::Document(message::Document {
692                data,
693                media_type: Some(message::DocumentMediaType::PDF),
694                ..
695            }) => match data {
696                DocumentSourceKind::Base64(b64) => Ok(UserContent::File {
697                    file: FileData {
698                        file_data: Some(format!("data:application/pdf;base64,{b64}")),
699                        file_id: None,
700                        filename: Some("document.pdf".to_string()),
701                    },
702                }),
703                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
704                    "OpenAI chat completions does not accept URL files; use the Responses API or pass base64-encoded bytes".into(),
705                )),
706                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
707                    "Raw files not supported, encode as base64 first".into(),
708                )),
709                DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
710                    "PDF documents must be base64-encoded, not raw strings".into(),
711                )),
712                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
713                    "File ID documents should be converted without media type constraints".into(),
714                )),
715                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
716                    "Document has no body".into(),
717                )),
718            },
719            message::UserContent::Document(message::Document { data, .. }) => {
720                if let DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text) = data {
721                    Ok(UserContent::Text { text })
722                } else {
723                    Err(message::MessageError::ConversionError(
724                        "Documents must be base64 or a string".into(),
725                    ))
726                }
727            }
728            message::UserContent::Audio(message::Audio {
729                data, media_type, ..
730            }) => match data {
731                DocumentSourceKind::Base64(data) => Ok(UserContent::Audio {
732                    input_audio: InputAudio {
733                        data,
734                        format: match media_type {
735                            Some(media_type) => media_type,
736                            None => AudioMediaType::MP3,
737                        },
738                    },
739                }),
740                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
741                    "URLs are not supported for audio".into(),
742                )),
743                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
744                    "Raw files are not supported for audio".into(),
745                )),
746                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
747                    "File IDs are not supported for audio".into(),
748                )),
749                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
750                    "Audio has no body".into(),
751                )),
752                audio => Err(message::MessageError::ConversionError(format!(
753                    "Unsupported audio type: {audio:?}"
754                ))),
755            },
756            message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
757                "Tool result is in unsupported format".into(),
758            )),
759            message::UserContent::Video(message::Video {
760                data, media_type, ..
761            }) => {
762                let url = match data {
763                    DocumentSourceKind::Url(url) => url,
764                    DocumentSourceKind::Base64(data) => {
765                        let mime = media_type
766                            .ok_or_else(|| {
767                                message::MessageError::ConversionError(
768                                    "Video media type required for base64 encoding".into(),
769                                )
770                            })?
771                            .to_mime_type();
772                        format!("data:{mime};base64,{data}")
773                    }
774                    DocumentSourceKind::Raw(_) => {
775                        return Err(message::MessageError::ConversionError(
776                            "Raw bytes not supported for video, encode as base64 first".into(),
777                        ));
778                    }
779                    DocumentSourceKind::FileId(_) => {
780                        return Err(message::MessageError::ConversionError(
781                            "File IDs are not supported for video".into(),
782                        ));
783                    }
784                    DocumentSourceKind::String(_) => {
785                        return Err(message::MessageError::ConversionError(
786                            "String source not supported for video".into(),
787                        ));
788                    }
789                    DocumentSourceKind::Unknown => {
790                        return Err(message::MessageError::ConversionError(
791                            "Video has no data".into(),
792                        ));
793                    }
794                };
795                Ok(UserContent::Video {
796                    video_url: VideoUrl { url },
797                })
798            }
799        }
800    }
801}
802
803/// Convert rig user content into OpenAI chat messages.
804///
805/// This was `impl TryFrom<OneOrMany<UserContent>> for Vec<Message>`. With the
806/// container gone both sides are foreign types, so the orphan rule forbids the
807/// impl and it becomes a named function. It stays `pub`: the impl was reachable
808/// from downstream code, and quietly demoting it to a private helper would
809/// narrow the public API under cover of a type change.
810pub fn user_content_to_messages(
811    value: Vec<message::UserContent>,
812) -> Result<Vec<Message>, message::MessageError> {
813    fn flush_user_content(messages: &mut Vec<Message>, pending: &mut Vec<UserContent>) {
814        // An empty flush is a legal no-op — it fires between consecutive
815        // tool-result groups — not a conversion error. This early return is
816        // the only emptiness decision here; the pushed content is non-empty
817        // because of it.
818        if pending.is_empty() {
819            return;
820        }
821
822        messages.push(Message::User {
823            content: std::mem::take(pending),
824            name: None,
825        });
826    }
827
828    let mut messages = Vec::new();
829    let mut pending = Vec::new();
830
831    for content in value {
832        match content {
833            message::UserContent::ToolResult(tool_result) => {
834                flush_user_content(&mut messages, &mut pending);
835                messages.push(tool_result.try_into()?);
836            }
837            content => pending.push(content.try_into()?),
838        }
839    }
840
841    flush_user_content(&mut messages, &mut pending);
842    Ok(messages)
843}
844
845/// Convert rig assistant content into OpenAI chat messages.
846///
847/// Free function for the same orphan-rule reason as
848/// [`user_content_to_messages`], and `pub` for the same API-surface reason.
849pub fn assistant_content_to_messages(
850    value: Vec<message::AssistantContent>,
851) -> Result<Vec<Message>, message::MessageError> {
852    let mut text_content = Vec::new();
853    let mut tool_calls = Vec::new();
854    // Distinct reasoning blocks are joined with a newline (matching
855    // `display_text()`'s own inter-block separator) rather than glued
856    // together, so replayed multi-block reasoning keeps its boundaries.
857    let mut reasoning_parts: Vec<String> = Vec::new();
858
859    for content in value {
860        match content {
861            message::AssistantContent::Text(text) => text_content.push(text),
862            message::AssistantContent::ToolCall(tool_call) => tool_calls.push(tool_call),
863            message::AssistantContent::Reasoning(reasoning) => {
864                let display = reasoning.display_text();
865                if !display.is_empty() {
866                    reasoning_parts.push(display);
867                }
868            }
869            message::AssistantContent::Image(_) => {
870                return Err(message::MessageError::ConversionError(
871                    "OpenAI assistant messages do not support image content in chat completions"
872                        .into(),
873                ));
874            }
875        }
876    }
877
878    if text_content.is_empty() && tool_calls.is_empty() {
879        return Ok(vec![]);
880    }
881
882    Ok(vec![Message::Assistant {
883        content: text_content
884            .into_iter()
885            .map(|content| content.text.into())
886            .collect::<Vec<_>>(),
887        reasoning: if reasoning_parts.is_empty() {
888            None
889        } else {
890            Some(reasoning_parts.join("\n"))
891        },
892        refusal: None,
893        audio: None,
894        name: None,
895        tool_calls: tool_calls
896            .into_iter()
897            .map(|tool_call| tool_call.into())
898            .collect::<Vec<_>>(),
899        reasoning_details: Vec::new(),
900        images: Vec::new(),
901    }])
902}
903
904impl TryFrom<message::Message> for Vec<Message> {
905    type Error = message::MessageError;
906
907    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
908        match message {
909            message::Message::System { content } => Ok(vec![Message::system(&content)]),
910            message::Message::User { content } => user_content_to_messages(content),
911            message::Message::Assistant { content, .. } => assistant_content_to_messages(content),
912        }
913    }
914}
915
916impl From<message::ToolCall> for ToolCall {
917    fn from(tool_call: message::ToolCall) -> Self {
918        Self {
919            // Keep the assistant echo consistent with the tool-result side:
920            // the provider-issued call id when one exists (e.g. a
921            // Responses-API history replayed via chat completions), else
922            // rig's minted handle — never empty.
923            id: tool_call.wire_call_id().to_owned(),
924            r#type: ToolType::default(),
925            function: Function {
926                name: tool_call.function.name,
927                arguments: tool_call.function.arguments,
928            },
929        }
930    }
931}
932
933impl From<ToolCall> for message::ToolCall {
934    fn from(tool_call: ToolCall) -> Self {
935        message::ToolCall::from_wire(
936            tool_call.id,
937            message::ToolFunction {
938                name: tool_call.function.name,
939                arguments: tool_call.function.arguments,
940            },
941        )
942    }
943}
944
945impl TryFrom<Message> for message::Message {
946    type Error = message::MessageError;
947
948    fn try_from(message: Message) -> Result<Self, Self::Error> {
949        Ok(match message {
950            Message::User { content, .. } => message::Message::User {
951                content: content.into_iter().map(|content| content.into()).collect(),
952            },
953            Message::Assistant {
954                content,
955                tool_calls,
956                reasoning,
957                refusal,
958                ..
959            } => {
960                let mut assistant_content = Vec::new();
961
962                if let Some(reasoning) = reasoning
963                    && !reasoning.is_empty()
964                {
965                    assistant_content.push(message::AssistantContent::reasoning(reasoning));
966                }
967
968                // Either/or, not both: the fallback fires only when no part
969                // carried text, so every part left is an empty one. Appending
970                // them anyway would put an empty text block on the wire beside
971                // the refusal and make this view of the message disagree with
972                // the one `normalize` builds, which drops empty parts.
973                if let Some(refusal) = assistant_refusal_fallback(&content, refusal.as_deref()) {
974                    assistant_content.push(message::AssistantContent::text(refusal));
975                } else {
976                    assistant_content.extend(content.into_iter().map(|content| match content {
977                        AssistantContent::Text { text, .. } => {
978                            message::AssistantContent::text(text)
979                        }
980                        AssistantContent::Refusal { refusal } => {
981                            message::AssistantContent::text(refusal)
982                        }
983                    }));
984                }
985
986                assistant_content.extend(
987                    tool_calls
988                        .into_iter()
989                        .map(|tool_call| Ok(message::AssistantContent::ToolCall(tool_call.into())))
990                        .collect::<Result<Vec<_>, _>>()?,
991                );
992
993                message::Message::Assistant {
994                    id: None,
995                    content: crate::message::require_non_empty(assistant_content, || {
996                        message::MessageError::ConversionError(
997                            "Neither `content` nor `tool_calls` was provided to the Message"
998                                .to_owned(),
999                        )
1000                    })?,
1001                }
1002            }
1003
1004            Message::ToolResult {
1005                tool_call_id,
1006                content,
1007            } => message::Message::User {
1008                // OpenAI chat tool messages carry no tool name; this
1009                // conversion is lossy for name-keyed wires.
1010                content: vec![message::UserContent::tool_result_from_wire(
1011                    tool_call_id,
1012                    "",
1013                    vec![message::ToolResultContent::text(content.as_text())],
1014                )],
1015            },
1016
1017            // System messages should get stripped out when converting messages, this is just a
1018            // stop gap to avoid obnoxious error handling or panic occurring.
1019            Message::System { content, .. } => message::Message::User {
1020                content: content
1021                    .into_iter()
1022                    .map(|content| message::UserContent::text(content.text))
1023                    .collect(),
1024            },
1025        })
1026    }
1027}
1028
1029impl From<UserContent> for message::UserContent {
1030    fn from(content: UserContent) -> Self {
1031        match content {
1032            UserContent::Text { text, .. } => message::UserContent::text(text),
1033            UserContent::Image { image_url } => {
1034                message::UserContent::image_url(image_url.url, None, image_url.detail)
1035            }
1036            UserContent::Audio { input_audio } => {
1037                message::UserContent::audio(input_audio.data, Some(input_audio.format))
1038            }
1039            UserContent::File {
1040                file: FileData {
1041                    file_data, file_id, ..
1042                },
1043            } => match file_data {
1044                Some(data_url) => {
1045                    let kind = match data_url.strip_prefix("data:application/pdf;base64,") {
1046                        Some(b64) => DocumentSourceKind::Base64(b64.to_string()),
1047                        None => DocumentSourceKind::String(data_url),
1048                    };
1049                    message::UserContent::Document(message::Document {
1050                        data: kind,
1051                        media_type: Some(message::DocumentMediaType::PDF),
1052                        additional_params: None,
1053                    })
1054                }
1055                None => match file_id {
1056                    Some(id) => message::UserContent::Document(message::Document {
1057                        data: DocumentSourceKind::FileId(id),
1058                        media_type: None,
1059                        additional_params: None,
1060                    }),
1061                    None => message::UserContent::text(String::new()),
1062                },
1063            },
1064            UserContent::Video { video_url } => {
1065                let decomposed = video_url
1066                    .url
1067                    .strip_prefix("data:")
1068                    .and_then(|rest| rest.split_once(";base64,"))
1069                    .and_then(|(mime, data)| {
1070                        // Only decompose data URIs whose media type survives
1071                        // the round trip; unrecognized MIMEs (e.g.
1072                        // video/quicktime, parameterized types) stay as URLs
1073                        // so re-serialization reproduces the original URI.
1074                        crate::message::VideoMediaType::from_mime_type(mime)
1075                            .map(|media_type| (media_type, data))
1076                    });
1077                match decomposed {
1078                    Some((media_type, data)) => message::UserContent::video(data, Some(media_type)),
1079                    None => message::UserContent::video_url(video_url.url, None),
1080                }
1081            }
1082        }
1083    }
1084}
1085
1086impl From<String> for UserContent {
1087    fn from(s: String) -> Self {
1088        UserContent::Text { text: s }
1089    }
1090}
1091
1092impl From<&str> for UserContent {
1093    fn from(s: &str) -> Self {
1094        s.to_owned().into()
1095    }
1096}
1097
1098impl FromStr for UserContent {
1099    type Err = Infallible;
1100
1101    fn from_str(s: &str) -> Result<Self, Self::Err> {
1102        Ok(s.to_owned().into())
1103    }
1104}
1105
1106impl From<String> for AssistantContent {
1107    fn from(s: String) -> Self {
1108        AssistantContent::Text { text: s }
1109    }
1110}
1111
1112impl FromStr for AssistantContent {
1113    type Err = Infallible;
1114
1115    fn from_str(s: &str) -> Result<Self, Self::Err> {
1116        Ok(s.to_owned().into())
1117    }
1118}
1119impl From<String> for SystemContent {
1120    fn from(s: String) -> Self {
1121        SystemContent {
1122            r#type: SystemContentType::default(),
1123            text: s,
1124        }
1125    }
1126}
1127
1128impl FromStr for SystemContent {
1129    type Err = Infallible;
1130
1131    fn from_str(s: &str) -> Result<Self, Self::Err> {
1132        Ok(s.to_owned().into())
1133    }
1134}
1135
1136#[derive(Clone, Debug, Deserialize, Serialize)]
1137pub struct CompletionResponse {
1138    pub id: String,
1139    // Null-or-missing tolerated on deserialization: some OpenAI-compatible
1140    // gateways (HuggingFace router sub-providers, TGI variants, Copilot's
1141    // multi-vendor chat route) omit them or send explicit `null`.
1142    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1143    pub object: String,
1144    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1145    pub created: u64,
1146    pub model: String,
1147    pub system_fingerprint: Option<String>,
1148    /// Service tier that processed the request, when OpenAI reports it.
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub service_tier: Option<String>,
1151    #[serde(
1152        deserialize_with = "crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls"
1153    )]
1154    pub choices: Vec<Choice>,
1155    pub usage: Option<Usage>,
1156}
1157
1158/// Normalize an OpenAI-compatible chat completion response.
1159///
1160/// The provider descriptor name is an *input* rather than a constant: this same
1161/// wire shape is shared by every OpenAI-compatible provider, so baking in
1162/// `"openai"` here would mislabel Groq, Together, DeepSeek and the rest. Taking
1163/// it as part of the conversion makes the correct name impossible to forget.
1164impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
1165    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
1166        use crate::providers::internal::openai_chat_completions_compatible as compat;
1167
1168        let usage = self
1169            .usage
1170            .as_ref()
1171            .map(crate::completion::Usage::from)
1172            .unwrap_or_default();
1173        compat::normalize_openai_response(
1174            provider,
1175            &self.choices,
1176            Some(self.id.as_str()),
1177            Some(self.model.as_str()),
1178            usage,
1179            |choice| choice.finish_reason.as_str(),
1180            |choice| match &choice.message {
1181                Message::Assistant {
1182                    content: wire_content,
1183                    tool_calls,
1184                    reasoning,
1185                    refusal,
1186                    ..
1187                } => {
1188                    let mut content = wire_content
1189                        .iter()
1190                        .filter_map(|c| {
1191                            let s = match c {
1192                                AssistantContent::Text { text, .. } => text,
1193                                AssistantContent::Refusal { refusal } => refusal,
1194                            };
1195                            if s.is_empty() {
1196                                None
1197                            } else {
1198                                Some(completion::AssistantContent::text(s))
1199                            }
1200                        })
1201                        .collect::<Vec<_>>();
1202
1203                    if let Some(refusal) =
1204                        assistant_refusal_fallback(wire_content, refusal.as_deref())
1205                    {
1206                        content.push(completion::AssistantContent::text(refusal));
1207                    }
1208
1209                    if let Some(reasoning) = reasoning {
1210                        // llama.cpp exposes hidden reasoning on a separate non-standard field.
1211                        // Keep it structured here so the non-streaming path matches streaming
1212                        // behavior and does not pollute plain-text response surfaces.
1213                        content.push(completion::AssistantContent::reasoning(reasoning));
1214                    }
1215
1216                    content.extend(tool_calls.iter().map(|call| {
1217                        completion::AssistantContent::tool_call(
1218                            &call.id,
1219                            &call.function.name,
1220                            call.function.arguments.clone(),
1221                        )
1222                    }));
1223                    Some(content)
1224                }
1225                _ => None,
1226            },
1227        )
1228    }
1229}
1230
1231impl ProviderResponseExt for CompletionResponse {
1232    type Usage = Usage;
1233
1234    fn get_response_id(&self) -> Option<String> {
1235        Some(self.id.to_owned())
1236    }
1237
1238    fn get_response_model_name(&self) -> Option<String> {
1239        Some(self.model.to_owned())
1240    }
1241
1242    fn get_text_response(&self) -> Option<String> {
1243        let response = self
1244            .choices
1245            .iter()
1246            .filter_map(|choice| assistant_message_text_response(&choice.message))
1247            .collect::<Vec<_>>()
1248            .join("\n");
1249
1250        if response.is_empty() {
1251            None
1252        } else {
1253            Some(response)
1254        }
1255    }
1256
1257    fn get_usage(&self) -> Option<Self::Usage> {
1258        self.usage.clone()
1259    }
1260}
1261
1262/// The assistant message's top-level `refusal`, when it is the turn's only
1263/// visible text.
1264///
1265/// This wire spells a refusal as a *sibling* of `content`
1266/// (`{"content": null, "refusal": "I'm sorry, …"}`); the `refusal` **content
1267/// part** modeled by [`AssistantContent::Refusal`] is the Responses API's
1268/// shape, which chat completions never sends. Every path that reads `content`
1269/// alone therefore drops a real refusal entirely, so all of them route the
1270/// fallback through here — one home for the rule, and no way for the raw text
1271/// view and the normalized response to disagree about whether a refusal is
1272/// content.
1273///
1274/// The verdict is taken from the wire parts themselves rather than from
1275/// whatever each caller built out of them, so a caller that discards empty
1276/// parts and one that keeps them cannot disagree about when the fallback
1277/// applies.
1278///
1279/// This is a *whole-message* rule and the three unary paths share it. The
1280/// streaming path cannot: it decides per delta, before it knows whether text
1281/// arrives later
1282/// ([`delta_text`](super::completion::streaming), which prefers a delta's own
1283/// content and falls back to its refusal). The two therefore agree on every
1284/// shape this wire has been observed to send — a refusal turn holds `content`
1285/// at `null` for its whole length — but would differ on a turn mixing both,
1286/// where this rule keeps only the text and the streaming rule would deliver
1287/// both in arrival order. That shape is pinned in
1288/// `delta_text_prefers_content_over_a_simultaneous_refusal` so the difference
1289/// is recorded rather than assumed away.
1290pub(crate) fn assistant_refusal_fallback<'a>(
1291    content: &[AssistantContent],
1292    refusal: Option<&'a str>,
1293) -> Option<&'a str> {
1294    let has_text = content.iter().any(|part| {
1295        !match part {
1296            AssistantContent::Text { text } => text,
1297            AssistantContent::Refusal { refusal } => refusal,
1298        }
1299        .is_empty()
1300    });
1301
1302    refusal.filter(|refusal| !has_text && !refusal.is_empty())
1303}
1304
1305pub(crate) fn assistant_message_text_response(message: &Message) -> Option<String> {
1306    let Message::Assistant {
1307        content, refusal, ..
1308    } = message
1309    else {
1310        return None;
1311    };
1312
1313    let mut segments = content
1314        .iter()
1315        .filter_map(|content| match content {
1316            AssistantContent::Text { text, .. } => (!text.is_empty()).then(|| text.clone()),
1317            AssistantContent::Refusal { refusal } => (!refusal.is_empty()).then(|| refusal.clone()),
1318        })
1319        .collect::<Vec<_>>();
1320
1321    if let Some(refusal) = assistant_refusal_fallback(content, refusal.as_deref()) {
1322        segments.push(refusal.to_owned());
1323    }
1324
1325    if segments.is_empty() {
1326        None
1327    } else {
1328        Some(segments.join("\n"))
1329    }
1330}
1331
1332#[derive(Clone, Debug, Serialize, Deserialize)]
1333pub struct Choice {
1334    // Null-or-missing tolerated on deserialization: Copilot's chat route
1335    // (fronting non-OpenAI vendors) can omit either field or send explicit
1336    // `null`; normalization treats "" as absent.
1337    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1338    pub index: usize,
1339    pub message: Message,
1340    pub logprobs: Option<serde_json::Value>,
1341    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1342    pub finish_reason: String,
1343}
1344
1345#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1346pub struct PromptTokensDetails {
1347    /// Cached tokens from prompt caching
1348    #[serde(default)]
1349    pub cached_tokens: usize,
1350}
1351
1352#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1353pub struct CompletionTokensDetails {
1354    /// Reasoning tokens reported by reasoning-capable providers.
1355    #[serde(default)]
1356    pub reasoning_tokens: usize,
1357}
1358
1359#[derive(Clone, Debug, Deserialize, Serialize)]
1360pub struct Usage {
1361    pub prompt_tokens: usize,
1362    #[serde(default, skip_serializing_if = "Option::is_none")]
1363    pub completion_tokens: Option<usize>,
1364    pub total_tokens: usize,
1365    #[serde(skip_serializing_if = "Option::is_none")]
1366    pub prompt_tokens_details: Option<PromptTokensDetails>,
1367    #[serde(default, skip_serializing_if = "Option::is_none")]
1368    pub completion_tokens_details: Option<CompletionTokensDetails>,
1369    #[serde(default, skip_serializing_if = "Option::is_none")]
1370    pub queue_time: Option<f64>,
1371    #[serde(default, skip_serializing_if = "Option::is_none")]
1372    pub prompt_time: Option<f64>,
1373    #[serde(default, skip_serializing_if = "Option::is_none")]
1374    pub completion_time: Option<f64>,
1375    #[serde(default, skip_serializing_if = "Option::is_none")]
1376    pub total_time: Option<f64>,
1377}
1378
1379impl Usage {
1380    pub fn new() -> Self {
1381        Self {
1382            prompt_tokens: 0,
1383            completion_tokens: None,
1384            total_tokens: 0,
1385            prompt_tokens_details: None,
1386            completion_tokens_details: None,
1387            queue_time: None,
1388            prompt_time: None,
1389            completion_time: None,
1390            total_time: None,
1391        }
1392    }
1393}
1394
1395impl Default for Usage {
1396    fn default() -> Self {
1397        Self::new()
1398    }
1399}
1400
1401impl fmt::Display for Usage {
1402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1403        let Usage {
1404            prompt_tokens,
1405            total_tokens,
1406            ..
1407        } = self;
1408        write!(
1409            f,
1410            "Prompt tokens: {prompt_tokens} Total tokens: {total_tokens}"
1411        )
1412    }
1413}
1414
1415impl From<&Usage> for crate::completion::Usage {
1416    fn from(value: &Usage) -> crate::completion::Usage {
1417        value.to_normalized()
1418    }
1419}
1420
1421impl From<Usage> for crate::completion::Usage {
1422    fn from(value: Usage) -> crate::completion::Usage {
1423        value.to_normalized()
1424    }
1425}
1426
1427impl Usage {
1428    /// Normalize this provider usage payload into rig's [`crate::completion::Usage`].
1429    pub fn to_normalized(&self) -> crate::completion::Usage {
1430        let mut usage = crate::providers::internal::completion_usage(
1431            self.prompt_tokens as u64,
1432            self.completion_tokens
1433                .unwrap_or_else(|| self.total_tokens.saturating_sub(self.prompt_tokens))
1434                as u64,
1435            self.total_tokens as u64,
1436            self.prompt_tokens_details
1437                .as_ref()
1438                .map(|d| d.cached_tokens as u64)
1439                .unwrap_or(0),
1440        );
1441        usage.reasoning_tokens = self
1442            .completion_tokens_details
1443            .as_ref()
1444            .map(|d| d.reasoning_tokens as u64)
1445            .unwrap_or(0);
1446        usage
1447    }
1448}
1449
1450/// Per-model options that affect request conversion/finalization for the shared
1451/// OpenAI-compatible chat-completions path.
1452#[derive(Debug, Clone, Copy, Default)]
1453pub struct CompletionModelOptions {
1454    /// Whether tool schemas should be sanitized for strict-mode validation.
1455    pub strict_tools: bool,
1456    /// Whether tool-result messages should serialize their content as arrays.
1457    pub tool_result_array_content: bool,
1458    /// Whether the model requested provider-specific prompt caching markers.
1459    pub prompt_caching: bool,
1460}
1461
1462/// Contract for provider extensions that speak the OpenAI Chat Completions wire
1463/// format through [`GenericCompletionModel`]. Mirrors
1464/// [`AnthropicCompatibleProvider`](crate::providers::anthropic::completion::AnthropicCompatibleProvider)
1465/// on the Anthropic-compatible side.
1466///
1467/// Request construction runs the hooks in a fixed order:
1468/// [`prepare_request`](Self::prepare_request) on the typed request, then
1469/// serialization, then (for streaming) the `stream`/`stream_options` merge,
1470/// and finally
1471/// [`finalize_request_body_with_options`](Self::finalize_request_body_with_options)
1472/// on the serialized body — so the finalize hook always sees the streaming
1473/// parameters and model-level options.
1474pub trait OpenAICompatibleProvider: crate::client::Provider {
1475    /// Provider name recorded on `gen_ai.provider.name` telemetry spans.
1476    const PROVIDER_NAME: &'static str;
1477
1478    /// Response header carrying the provider's transport request id, when the
1479    /// provider reports one (OpenAI sends `x-request-id`). `None` — the
1480    /// default — means the provider does not report one; the normalized
1481    /// response's `provider_request_id` is then `None`, never an error.
1482    const REQUEST_ID_HEADER: Option<&'static str> = None;
1483
1484    /// Whether the backend can emit a whole tool call (id, name, and complete
1485    /// arguments) in a single streaming chunk, as llama.cpp-based servers do.
1486    /// When true, the shared streaming layer emits such calls as soon as they
1487    /// arrive instead of holding them until the stream ends.
1488    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = false;
1489
1490    /// Whether the provider supports tool calling. When false, `tools` and
1491    /// `tool_choice` are dropped with a warning during request conversion —
1492    /// before tool-choice validation, so unsupported tool configurations
1493    /// never error client-side on a provider that ignores tools anyway.
1494    const SUPPORTS_TOOLS: bool = true;
1495
1496    /// Whether `output_schema` maps to OpenAI's `response_format`. Providers
1497    /// whose APIs reject `json_schema` response formats set this to false;
1498    /// the schema is then dropped with a warning instead of being sent.
1499    const SUPPORTS_RESPONSE_FORMAT: bool = true;
1500
1501    /// Whether streaming requests include
1502    /// `"stream_options": {"include_usage": true}`. Providers that reject
1503    /// unknown parameters and already report usage on the final chunk set
1504    /// this to false.
1505    const STREAM_INCLUDE_USAGE: bool = true;
1506
1507    /// Map a streamed terminal reason for this compatible provider.
1508    ///
1509    /// The normalized Chat Completions field is the default contract. Gateway
1510    /// providers that also expose an upstream-native reason can override this
1511    /// to apply their documented precedence without teaching the shared wire
1512    /// adapter provider names or native vocabularies.
1513    fn map_streaming_finish_reason(
1514        &self,
1515        finish_reason: Option<&str>,
1516        _native_finish_reason: Option<&str>,
1517    ) -> Option<crate::completion::FinishReason> {
1518        finish_reason
1519            .filter(|reason| !reason.is_empty())
1520            .map(crate::providers::internal::openai_chat_completions_compatible::map_openai_finish_reason)
1521    }
1522
1523    /// Whether `model`'s endpoint rejects the legacy `max_tokens` field and
1524    /// requires `max_completion_tokens` instead.
1525    ///
1526    /// OpenAI's reasoning-class models answer a capped request with
1527    /// `"Unsupported parameter: 'max_tokens' is not supported with this model.
1528    /// Use 'max_completion_tokens' instead."`, so such a request cannot
1529    /// succeed at all until the field is respelled.
1530    ///
1531    /// Scoped to the model rather than applied to every request on purpose:
1532    /// this same extension is how rig reaches OpenAI-*compatible* servers
1533    /// (mistral.rs, vLLM, llama.cpp, gateways), whose endpoints mostly know
1534    /// only the legacy field, and OpenAI's own non-reasoning models still take
1535    /// it. Everything outside the returned set keeps the bytes it always sent.
1536    /// The default is `false` — a provider that has not been observed to
1537    /// reject the legacy field says so by saying nothing.
1538    ///
1539    /// Azure OpenAI deliberately keeps the default even though it fronts the
1540    /// same models: an Azure model handle is a *deployment* name chosen by the
1541    /// account owner, so it carries no family information to classify. A
1542    /// capped reasoning deployment there still gets the provider's explicit
1543    /// `Unsupported parameter` error, which is the honest outcome until Azure
1544    /// can be given a signal that does not require guessing.
1545    fn requires_modern_output_cap(&self, model: &str) -> bool {
1546        let _ = model;
1547        false
1548    }
1549
1550    /// The usage payload parsed from streaming chunks and carried on the
1551    /// final streaming response. OpenAI's [`Usage`] for most providers;
1552    /// providers with richer usage accounting (e.g. Mistral's cached-token
1553    /// fallbacks, DeepSeek's cache hit/miss counters) substitute their own.
1554    type StreamingUsage: Clone
1555        + Default
1556        + Into<crate::completion::Usage>
1557        + Serialize
1558        + serde::de::DeserializeOwned
1559        + Unpin
1560        + WasmCompatSend
1561        + WasmCompatSync
1562        + 'static;
1563
1564    /// The chat-completions payload this provider returns.
1565    ///
1566    /// The normalization bound is stated over `(&str, Self::Response)` so the
1567    /// provider descriptor name is threaded through the conversion instead of
1568    /// being hardcoded by whichever wire type happens to implement it.
1569    type Response: serde::de::DeserializeOwned
1570        + Serialize
1571        + crate::telemetry::ProviderResponseExt<Usage: Into<crate::completion::Usage>>
1572        + crate::completion::NormalizeCompletionResponse
1573        + WasmCompatSend
1574        + WasmCompatSync;
1575
1576    /// The request path for chat completions, resolved against the client
1577    /// base URL by [`Provider::build_uri`](crate::client::Provider::build_uri).
1578    /// Providers that route the model through the URL (e.g. Azure deployment
1579    /// paths) or keep other capabilities on differently-versioned paths
1580    /// override this. `model` is the identifier the completion model handle
1581    /// was created with; per-request model overrides only affect the body.
1582    fn completion_path(&self, model: &str) -> String {
1583        let _ = model;
1584        "/chat/completions".to_string()
1585    }
1586
1587    /// Build the typed chat-completions request. Providers that share the
1588    /// OpenAI transport but need provider-specific message conversion can
1589    /// override this while still using [`GenericCompletionModel`] for sending,
1590    /// streaming, error handling, and telemetry.
1591    fn build_completion_request(
1592        &self,
1593        model: String,
1594        request: CoreCompletionRequest,
1595        options: CompletionModelOptions,
1596    ) -> Result<CompletionRequest, CompletionError> {
1597        CompletionRequest::try_from(OpenAIRequestParams {
1598            model,
1599            request,
1600            strict_tools: options.strict_tools,
1601            tool_result_array_content: options.tool_result_array_content,
1602            supports_response_format: Self::SUPPORTS_RESPONSE_FORMAT,
1603            supports_tools: Self::SUPPORTS_TOOLS,
1604        })
1605    }
1606
1607    /// Adjust the typed request before serialization (e.g. rewrite the model
1608    /// identifier or fold provider-native tool definitions out of
1609    /// `additional_params`).
1610    fn prepare_request(&self, request: &mut CompletionRequest) -> Result<(), CompletionError> {
1611        let _ = request;
1612        Ok(())
1613    }
1614
1615    /// Adjust the fully serialized request body — after any streaming
1616    /// parameters are merged — immediately before it is sent. This is where
1617    /// wire-level dialect differences live (e.g. Mistral's `"any"` tool
1618    /// choice, DeepSeek's string-flattened message content).
1619    fn finalize_request_body(&self, body: &mut serde_json::Value) -> Result<(), CompletionError> {
1620        let _ = body;
1621        Ok(())
1622    }
1623
1624    /// Adjust the fully serialized request body with model-level options.
1625    /// Providers that do not need model-instance options should override
1626    /// [`finalize_request_body`](Self::finalize_request_body) instead.
1627    fn finalize_request_body_with_options(
1628        &self,
1629        body: &mut serde_json::Value,
1630        options: CompletionModelOptions,
1631    ) -> Result<(), CompletionError> {
1632        let _ = options;
1633        self.finalize_request_body(body)
1634    }
1635
1636    /// Map a provider-specific streaming detail payload onto a complete
1637    /// reasoning block — its identity and content — that the stream emits as
1638    /// the turn's own output. OpenRouter's `reasoning_details` entries of type
1639    /// `reasoning.encrypted` are the in-tree case: the wire carries them with
1640    /// `reasoning: null`, so this hook is the only place they can reach the
1641    /// aggregated choice (and, from there, the next turn's request).
1642    ///
1643    /// A detail maps to *either* a reasoning block or a
1644    /// [`decoration`](Self::decorate_streaming_tool_call), never both.
1645    fn streaming_detail_reasoning(
1646        &self,
1647        detail: &serde_json::Value,
1648    ) -> Option<(
1649        crate::streaming::StreamPartId,
1650        Option<crate::streaming::WireId>,
1651        crate::message::ReasoningContent,
1652    )> {
1653        let _ = detail;
1654        None
1655    }
1656
1657    /// Extract a signature-only reasoning detail from a streamed compatible
1658    /// response. The default wire has no such extension; gateway providers
1659    /// can attach the signature to the shared plaintext reasoning lifecycle.
1660    fn streaming_reasoning_signature(&self, detail: &serde_json::Value) -> Option<String> {
1661        let _ = detail;
1662        None
1663    }
1664
1665    /// Decorate a streamed tool call from a provider-specific streaming
1666    /// detail payload, matched by its established provider id. Most
1667    /// OpenAI-compatible providers do not emit such details.
1668    ///
1669    /// The decoration is an adapter-level event rewrite: it rides the
1670    /// adapter's tool-input end event onto the completed call; fragment
1671    /// assembly itself lives in the shared accumulator.
1672    fn decorate_streaming_tool_call(
1673        &self,
1674        detail: &serde_json::Value,
1675    ) -> Option<crate::streaming::ToolCallDecoration> {
1676        let _ = detail;
1677        None
1678    }
1679}
1680
1681impl OpenAICompatibleProvider for super::OpenAICompletionsExt {
1682    const PROVIDER_NAME: &'static str = "openai";
1683    const REQUEST_ID_HEADER: Option<&'static str> = Some("x-request-id");
1684
1685    type StreamingUsage = Usage;
1686    type Response = CompletionResponse;
1687
1688    fn requires_modern_output_cap(&self, model: &str) -> bool {
1689        is_openai_reasoning_model(model)
1690    }
1691}
1692
1693/// Whether `model` names one of OpenAI's reasoning families, which take the
1694/// output cap only as `max_completion_tokens`.
1695///
1696/// Matched by family prefix rather than by an exhaustive list of releases: the
1697/// families are `gpt-5` and up, and the `o`-series (`o1`, `o3-mini`,
1698/// `o4-mini`, …), and each gains dated snapshots and size variants that an
1699/// enumerated list could not keep up with. A future family this misses keeps
1700/// today's behavior — the legacy field, and the provider's own explicit
1701/// `Unsupported parameter` error — rather than silently sending a field some
1702/// other backend does not know.
1703pub(crate) fn is_openai_reasoning_model(model: &str) -> bool {
1704    /// `gpt-5` … `gpt-9`, in any spelling the family uses (`gpt-5`,
1705    /// `gpt-5.1`, `gpt-5-nano`, `gpt-5-2025-08-07`).
1706    ///
1707    /// The major version is a single digit on purpose. Every released
1708    /// generation is spelled `gpt-<digit>` or `gpt-<digit>.<minor>`, so a
1709    /// multi-digit run (`gpt-45`, or a compatible server's own model name) is
1710    /// not a generation number and must not be read as one. A hypothetical
1711    /// `gpt-10` would fall through to the legacy field — today's behavior, and
1712    /// a visible provider error — rather than a field its backend may not know.
1713    fn is_numbered_gpt_family(model: &str, lowest: u32) -> bool {
1714        model
1715            .strip_prefix("gpt-")
1716            .and_then(|rest| rest.split(['.', '-']).next())
1717            .filter(|major| major.len() == 1)
1718            .and_then(|major| major.parse::<u32>().ok())
1719            .is_some_and(|major| major >= lowest)
1720    }
1721
1722    /// `o1`, `o3`, `o4`, … — but not `openai-…` or any other `o` word.
1723    fn is_o_series(model: &str) -> bool {
1724        let mut chars = model.chars();
1725        chars.next() == Some('o')
1726            && chars.next().is_some_and(|digit| digit.is_ascii_digit())
1727            && chars
1728                .next()
1729                .is_none_or(|next| next == '-' || next.is_ascii_digit())
1730    }
1731
1732    is_numbered_gpt_family(model, 5) || is_o_series(model)
1733}
1734
1735/// Serialize a chat-completions request into the body the target endpoint
1736/// expects, applying the spellings that depend on the endpoint rather than on
1737/// the request.
1738///
1739/// Both the unary and the streaming path build their body through here so the
1740/// two cannot disagree about what rig sends.
1741pub(crate) fn request_body(
1742    request: &CompletionRequest,
1743    modern_output_cap: bool,
1744) -> Result<serde_json::Value, CompletionError> {
1745    let mut body = serde_json::to_value(request)?;
1746
1747    if modern_output_cap
1748        && let Some(object) = body.as_object_mut()
1749        && let Some(max_tokens) = object.remove("max_tokens")
1750    {
1751        // A caller who spelled the modern field themselves (through
1752        // `additional_params`) keeps their own value; the legacy key still has
1753        // to go, since reasoning models reject its mere presence — and behind
1754        // this endpoint there is no backend that wants it.
1755        object.entry("max_completion_tokens").or_insert(max_tokens);
1756    }
1757
1758    Ok(body)
1759}
1760
1761/// A chat-completions model over any [`OpenAICompatibleProvider`] extension.
1762/// This is the advertised path for OpenAI-compatible providers; see the
1763/// provider checklist in [`crate::providers`].
1764#[derive(Clone)]
1765pub struct GenericCompletionModel<Ext = super::OpenAICompletionsExt, H = reqwest::Client> {
1766    pub(crate) client: crate::client::Client<Ext, H>,
1767    pub model: String,
1768    pub(crate) strict_tools: bool,
1769    pub(crate) tool_result_array_content: bool,
1770    pub(crate) prompt_caching: bool,
1771}
1772
1773/// The completion model struct for OpenAI's Chat Completions API.
1774///
1775/// This preserves the historical public generic shape where the first generic
1776/// parameter is the HTTP client type.
1777pub type CompletionModel<H = reqwest::Client> =
1778    GenericCompletionModel<super::OpenAICompletionsExt, H>;
1779
1780impl<Ext, H> GenericCompletionModel<Ext, H>
1781where
1782    crate::client::Client<Ext, H>: std::fmt::Debug + Clone + 'static,
1783    Ext: crate::client::Provider + Clone + 'static,
1784{
1785    pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1786        Self {
1787            client,
1788            model: model.into(),
1789            strict_tools: false,
1790            tool_result_array_content: false,
1791            prompt_caching: false,
1792        }
1793    }
1794
1795    /// Enable strict mode for tool schemas.
1796    ///
1797    /// When enabled, tool schemas are automatically sanitized to meet OpenAI's strict mode requirements:
1798    /// - `additionalProperties: false` is added to all objects
1799    /// - All properties are marked as required
1800    /// - `strict: true` is set on each function definition
1801    ///
1802    /// This allows OpenAI to guarantee that the model's tool calls will match the schema exactly.
1803    pub fn with_strict_tools(mut self) -> Self {
1804        self.strict_tools = true;
1805        self
1806    }
1807
1808    pub fn with_tool_result_array_content(mut self) -> Self {
1809        self.tool_result_array_content = true;
1810        self
1811    }
1812}
1813
1814#[derive(Debug, Serialize, Deserialize, Clone)]
1815pub struct CompletionRequest {
1816    pub model: String,
1817    pub messages: Vec<Message>,
1818    #[serde(skip_serializing_if = "Vec::is_empty")]
1819    pub tools: Vec<ToolDefinition>,
1820    #[serde(skip_serializing_if = "Option::is_none")]
1821    pub tool_choice: Option<ToolChoice>,
1822    #[serde(skip_serializing_if = "Option::is_none")]
1823    pub temperature: Option<f64>,
1824    #[serde(skip_serializing_if = "Option::is_none")]
1825    pub max_tokens: Option<u64>,
1826    #[serde(flatten)]
1827    pub additional_params: Option<serde_json::Value>,
1828}
1829
1830/// Shared helper for provider `finalize_request_body` hooks whose APIs take
1831/// message `content` as a plain string: flattens a content-part array to the
1832/// concatenation of its text parts. When `only_if_all_text` is set, arrays
1833/// containing non-text parts are left untouched (for APIs with their own
1834/// multimodal handling); otherwise non-text parts are dropped.
1835pub(crate) fn flatten_text_content_parts(
1836    content: &mut serde_json::Value,
1837    separator: &str,
1838    only_if_all_text: bool,
1839) {
1840    // Refusals are textual content too; flatten them alongside text parts.
1841    // Checked per key so a null-padded `text` next to a string `refusal`
1842    // still counts as textual.
1843    fn part_text(part: &serde_json::Value) -> Option<&str> {
1844        part.get("text")
1845            .and_then(serde_json::Value::as_str)
1846            .or_else(|| part.get("refusal").and_then(serde_json::Value::as_str))
1847    }
1848
1849    let Some(parts) = content.as_array() else {
1850        return;
1851    };
1852    if only_if_all_text && !parts.iter().all(|part| part_text(part).is_some()) {
1853        return;
1854    }
1855    let mut flattened = String::new();
1856    for text in parts.iter().filter_map(part_text) {
1857        if !flattened.is_empty() {
1858            flattened.push_str(separator);
1859        }
1860        flattened.push_str(text);
1861    }
1862    *content = serde_json::Value::String(flattened);
1863}
1864
1865/// Joins the `text` fields of `type == "text"` content parts, in order.
1866pub(crate) fn joined_text_parts(parts: &[serde_json::Value]) -> String {
1867    parts
1868        .iter()
1869        .filter_map(|part| {
1870            (part.get("type").and_then(serde_json::Value::as_str) == Some("text"))
1871                .then(|| part.get("text").and_then(serde_json::Value::as_str))
1872                .flatten()
1873        })
1874        .collect::<Vec<_>>()
1875        .join("")
1876}
1877
1878/// Shared helper for provider `finalize_request_body` hooks whose APIs only
1879/// accept plain `{role, content}` chat messages: removes tool-exchange
1880/// remnants left in shared histories (role `tool` messages, assistant
1881/// `tool_calls`/`reasoning_content`), optionally flattens content-part arrays
1882/// to strings, and drops assistant turns left without content (pure
1883/// tool-call scaffolding). With `merge_same_role`, consecutive same-role
1884/// string-content messages are additionally merged — the removals can leave
1885/// user/user as well as assistant/assistant adjacency, and alternation-strict
1886/// APIs (Perplexity) reject both; providers without that constraint keep
1887/// their turns separate.
1888pub(crate) fn sanitize_plain_text_history(
1889    messages: &mut Vec<serde_json::Value>,
1890    flatten: Option<(&str, bool)>,
1891    strip_names: bool,
1892    merge_same_role: bool,
1893) {
1894    messages
1895        .retain(|message| message.get("role").and_then(serde_json::Value::as_str) != Some("tool"));
1896
1897    for message in messages.iter_mut() {
1898        let Some(object) = message.as_object_mut() else {
1899            continue;
1900        };
1901        if object.get("role").and_then(serde_json::Value::as_str) == Some("assistant") {
1902            object.remove("tool_calls");
1903            object.remove("reasoning_content");
1904        }
1905        if strip_names {
1906            object.remove("name");
1907        }
1908        if let Some((separator, only_if_all_text)) = flatten
1909            && let Some(content) = object.get_mut("content")
1910        {
1911            flatten_text_content_parts(content, separator, only_if_all_text);
1912        }
1913    }
1914
1915    messages.retain(|message| {
1916        if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
1917            return true;
1918        }
1919        match message.get("content") {
1920            Some(serde_json::Value::String(text)) => !text.is_empty(),
1921            Some(serde_json::Value::Null) | None => false,
1922            Some(_) => true,
1923        }
1924    });
1925
1926    if !merge_same_role {
1927        return;
1928    }
1929
1930    let mut merged: Vec<serde_json::Value> = Vec::with_capacity(messages.len());
1931    for message in std::mem::take(messages) {
1932        let merged_text = if let Some(role) = message
1933            .get("role")
1934            .and_then(serde_json::Value::as_str)
1935            .filter(|role| matches!(*role, "assistant" | "user"))
1936            && let Some(previous) = merged.last()
1937            && previous.get("role").and_then(serde_json::Value::as_str) == Some(role)
1938            && let Some(previous_text) = previous.get("content").and_then(serde_json::Value::as_str)
1939            && let Some(text) = message.get("content").and_then(serde_json::Value::as_str)
1940        {
1941            Some(format!("{previous_text}\n{text}"))
1942        } else {
1943            None
1944        };
1945
1946        if let Some(text) = merged_text
1947            && let Some(previous) = merged.last_mut().and_then(serde_json::Value::as_object_mut)
1948        {
1949            previous.insert("content".to_string(), serde_json::Value::String(text));
1950            continue;
1951        }
1952        merged.push(message);
1953    }
1954    *messages = merged;
1955}
1956
1957pub struct OpenAIRequestParams {
1958    pub model: String,
1959    pub request: CoreCompletionRequest,
1960    pub strict_tools: bool,
1961    pub tool_result_array_content: bool,
1962    /// Maps `output_schema` to `response_format` when true; drops it with a
1963    /// warning when false (providers whose APIs reject `json_schema`).
1964    pub supports_response_format: bool,
1965    /// Serializes `tools`/`tool_choice` when true; drops them with a warning
1966    /// when false (providers without tool-calling support).
1967    pub supports_tools: bool,
1968}
1969
1970impl TryFrom<OpenAIRequestParams> for CompletionRequest {
1971    type Error = CompletionError;
1972
1973    fn try_from(params: OpenAIRequestParams) -> Result<Self, Self::Error> {
1974        let OpenAIRequestParams {
1975            model,
1976            request: req,
1977            strict_tools,
1978            tool_result_array_content,
1979            supports_response_format,
1980            supports_tools,
1981        } = params;
1982        let chat_history = req.chat_history_with_documents();
1983
1984        let CoreCompletionRequest {
1985            model: request_model,
1986            preamble,
1987            chat_history: _,
1988            tools,
1989            temperature,
1990            max_tokens,
1991            additional_params,
1992            tool_choice,
1993            output_schema,
1994            ..
1995        } = req;
1996
1997        let mut partial_history = Vec::new();
1998        partial_history.extend(chat_history);
1999
2000        let mut full_history: Vec<Message> =
2001            preamble.map_or_else(Vec::new, |preamble| vec![Message::system(&preamble)]);
2002
2003        full_history.extend(
2004            partial_history
2005                .into_iter()
2006                .map(message::Message::try_into)
2007                .collect::<Result<Vec<Vec<Message>>, _>>()?
2008                .into_iter()
2009                .flatten()
2010                .collect::<Vec<_>>(),
2011        );
2012
2013        if full_history.is_empty() {
2014            return Err(CompletionError::RequestError(
2015                std::io::Error::new(
2016                    std::io::ErrorKind::InvalidInput,
2017                    "OpenAI Chat Completions request has no provider-compatible messages after conversion",
2018                )
2019                .into(),
2020            ));
2021        }
2022
2023        for msg in &mut full_history {
2024            if let Message::ToolResult { content, .. } = msg {
2025                let normalized = if tool_result_array_content {
2026                    content.to_array()
2027                } else {
2028                    ToolResultContentValue::String(content.as_text())
2029                };
2030
2031                *content = normalized;
2032            }
2033        }
2034
2035        let history_has_tool_result = history_contains_tool_result(&full_history);
2036
2037        let (mut tools, tool_choice) = if supports_tools {
2038            let tool_choice = tool_choice.map(ToolChoice::try_from).transpose()?;
2039            let tools: Vec<ToolDefinition> = tools
2040                .into_iter()
2041                .map(|tool| {
2042                    let def = ToolDefinition::from(tool);
2043                    if strict_tools { def.with_strict() } else { def }
2044                })
2045                .collect();
2046            (tools, tool_choice)
2047        } else {
2048            if !tools.is_empty() {
2049                tracing::warn!("Tool use is not supported by this provider; tools will be ignored");
2050            }
2051            if tool_choice.is_some() {
2052                tracing::warn!("Tool choice is not supported by this provider and will be ignored");
2053            }
2054            (Vec::new(), None)
2055        };
2056
2057        // `additional_params` is flattened into the serialized request, so a raw
2058        // `tools` array left in it would silently replace the typed `tools`
2059        // field (the body is built via `serde_json::to_value`, where the
2060        // flattened key wins). Merge its function tools into the typed list
2061        // instead, mirroring the Responses API path (issue #1890). Entries that
2062        // are not function tools stay behind for the provider's
2063        // `prepare_request` hook — Groq, for one, folds its native tools
2064        // (`{"type": "browser_search"}`, ...) into `compound_custom` from there.
2065        let mut additional_params = additional_params;
2066        if supports_tools
2067            && let Some(map) = additional_params
2068                .as_mut()
2069                .and_then(serde_json::Value::as_object_mut)
2070            && let Some(raw_tools) = map.remove("tools")
2071        {
2072            let raw_tools =
2073                serde_json::from_value::<Vec<serde_json::Value>>(raw_tools).map_err(|err| {
2074                    CompletionError::RequestError(
2075                        format!(
2076                            "Invalid OpenAI Chat Completions `additional_params.tools` payload: {err}"
2077                        )
2078                        .into(),
2079                    )
2080                })?;
2081            let mut remaining = Vec::new();
2082            for raw_tool in raw_tools {
2083                let is_function_tool =
2084                    raw_tool.get("type").and_then(serde_json::Value::as_str) == Some("function");
2085                if is_function_tool {
2086                    let tool =
2087                        serde_json::from_value::<ToolDefinition>(raw_tool).map_err(|err| {
2088                            CompletionError::RequestError(
2089                                format!(
2090                                    "Invalid function tool in OpenAI Chat Completions \
2091                                 `additional_params.tools`: {err}"
2092                                )
2093                                .into(),
2094                            )
2095                        })?;
2096                    tools.push(tool);
2097                } else {
2098                    remaining.push(raw_tool);
2099                }
2100            }
2101            if !remaining.is_empty() {
2102                map.insert("tools".to_string(), serde_json::Value::Array(remaining));
2103            }
2104        }
2105
2106        if output_schema.is_some() && !supports_response_format {
2107            tracing::warn!(
2108                "Structured outputs are not supported by this provider; ignoring output_schema"
2109            );
2110        }
2111
2112        // Some OpenAI-compatible backends such as llama.cpp will skip tool execution
2113        // if `response_format` is sent on the first turn alongside tools. Delay the
2114        // schema until after the conversation contains a tool result.
2115        let should_apply_response_format = output_schema.is_some()
2116            && supports_response_format
2117            && (tools.is_empty() || history_has_tool_result);
2118
2119        // Map output_schema to OpenAI's response_format and merge into additional_params
2120        let additional_params = if let Some(schema) = output_schema
2121            && should_apply_response_format
2122        {
2123            let (name, schema_value) = super::structured_output_schema(schema);
2124            let response_format = serde_json::json!({
2125                "response_format": {
2126                    "type": "json_schema",
2127                    "json_schema": {
2128                        "name": name,
2129                        "strict": true,
2130                        "schema": schema_value
2131                    }
2132                }
2133            });
2134            Some(match additional_params {
2135                Some(existing) => json_utils::merge(existing, response_format),
2136                None => response_format,
2137            })
2138        } else {
2139            additional_params
2140        };
2141
2142        let res = Self {
2143            model: request_model.unwrap_or(model),
2144            messages: full_history,
2145            tools,
2146            tool_choice,
2147            temperature,
2148            max_tokens,
2149            additional_params,
2150        };
2151
2152        Ok(res)
2153    }
2154}
2155
2156impl TryFrom<(String, CoreCompletionRequest)> for CompletionRequest {
2157    type Error = CompletionError;
2158
2159    fn try_from((model, req): (String, CoreCompletionRequest)) -> Result<Self, Self::Error> {
2160        CompletionRequest::try_from(OpenAIRequestParams {
2161            model,
2162            request: req,
2163            strict_tools: false,
2164            tool_result_array_content: false,
2165            supports_response_format: true,
2166            supports_tools: true,
2167        })
2168    }
2169}
2170
2171impl<Ext, H> GenericCompletionModel<Ext, H>
2172where
2173    Ext: OpenAICompatibleProvider,
2174{
2175    /// Whether outgoing requests for `model` spell the output-token cap
2176    /// `max_completion_tokens`; see
2177    /// [`OpenAICompatibleProvider::requires_modern_output_cap`].
2178    ///
2179    /// `model` is the request's resolved model, not the handle's: a per-request
2180    /// override changes which endpoint answers, so it has to decide the
2181    /// spelling too.
2182    pub(crate) fn sends_modern_output_cap(&self, model: &str) -> bool {
2183        self.client.ext().requires_modern_output_cap(model)
2184    }
2185}
2186
2187impl<Ext, H> GenericCompletionModel<Ext, H>
2188where
2189    crate::client::Client<Ext, H>:
2190        HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
2191    Ext: crate::client::Provider
2192        + OpenAICompatibleProvider
2193        + crate::client::DebugExt
2194        + Clone
2195        + WasmCompatSend
2196        + WasmCompatSync
2197        + 'static,
2198    H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
2199{
2200    /// Execute a chat completion and return the provider's own wire response.
2201    ///
2202    /// This is the escape hatch for provider-specific fields rig does not
2203    /// normalize. It shares the request builder, transport, telemetry, and
2204    /// error handling with
2205    /// [`CompletionModel::completion`](completion::CompletionModel::completion),
2206    /// which calls it and then applies the provider-local mapping — one
2207    /// network request either way.
2208    ///
2209    /// The transport request id is not on the wire type and is dropped here;
2210    /// use [`Self::raw_completion_with_request_id`] when the typed route must
2211    /// reproduce everything `completion` returns.
2212    pub async fn raw_completion(
2213        &self,
2214        completion_request: CoreCompletionRequest,
2215    ) -> Result<Ext::Response, CompletionError> {
2216        self.raw_completion_with_request_id(completion_request)
2217            .await
2218            .map(|(response, _)| response)
2219    }
2220
2221    /// [`Self::raw_completion`] plus the transport request id from the
2222    /// provider's request-id response header ([`OpenAICompatibleProvider::REQUEST_ID_HEADER`]).
2223    ///
2224    /// The pair exists because the wire type is substitutable — `Ext::Response`
2225    /// is whatever the compatible provider parses — so the transport id cannot
2226    /// live on it, while the normalized [`completion::CompletionResponse`]
2227    /// carries one. Without this method, `raw_completion(..)` followed by
2228    /// [`normalize`](crate::completion::NormalizeCompletionResponse::normalize)
2229    /// would silently lack the `provider_request_id` that
2230    /// [`CompletionModel::completion`](completion::CompletionModel::completion)
2231    /// reports — the typed escape hatch would not reproduce the normalized
2232    /// path. Reassemble with
2233    /// [`with_optional_provider_request_id`](completion::CompletionResponse::with_optional_provider_request_id).
2234    pub async fn raw_completion_with_request_id(
2235        &self,
2236        completion_request: CoreCompletionRequest,
2237    ) -> Result<(Ext::Response, Option<String>), CompletionError> {
2238        let system_instructions = completion_request.preamble.clone();
2239        let record_telemetry_content = completion_request.record_telemetry_content;
2240        let options = CompletionModelOptions {
2241            strict_tools: self.strict_tools,
2242            tool_result_array_content: self.tool_result_array_content,
2243            prompt_caching: self.prompt_caching,
2244        };
2245        let mut request = self.client.ext().build_completion_request(
2246            self.model.to_owned(),
2247            completion_request,
2248            options,
2249        )?;
2250        self.client.ext().prepare_request(&mut request)?;
2251        let span = CompletionSpanBuilder::new(
2252            Ext::PROVIDER_NAME,
2253            &request.model,
2254            CompletionOperation::Chat,
2255        )
2256        .system_instructions(system_instructions.as_deref(), record_telemetry_content)
2257        .build();
2258
2259        let modern_output_cap = self.sends_modern_output_cap(&request.model);
2260        let mut request_body = request_body(&request, modern_output_cap)?;
2261        self.client
2262            .ext()
2263            .finalize_request_body_with_options(&mut request_body, options)?;
2264        crate::providers::internal::trace_json(
2265            crate::providers::internal::LogTarget::Completions,
2266            "OpenAI Chat Completions completion request",
2267            &request_body,
2268        );
2269
2270        let body = serde_json::to_vec(&request_body)?;
2271        // Deliberately the configured model, not the per-request override:
2272        // Azure's deployment URL is pinned to the model handle.
2273        let path = self.client.ext().completion_path(&self.model);
2274
2275        let req = self
2276            .client
2277            .post(&path)?
2278            .body(body)
2279            .map_err(|e| CompletionError::HttpError(e.into()))?;
2280
2281        send_completion::<_, ApiResponse<Ext::Response>, _>(
2282            &self.client,
2283            req,
2284            "OpenAI Chat Completions completion",
2285            Ext::REQUEST_ID_HEADER,
2286            |response| {
2287                let span = tracing::Span::current();
2288                span.record_response_metadata(response);
2289                let usage = response.get_usage().map(Into::into).unwrap_or_default();
2290                span.record_token_usage(&usage);
2291            },
2292        )
2293        .instrument(span)
2294        .await
2295    }
2296}
2297
2298impl<Ext, H> completion::CompletionModel for GenericCompletionModel<Ext, H>
2299where
2300    crate::client::Client<Ext, H>:
2301        HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
2302    Ext: crate::client::Provider
2303        + OpenAICompatibleProvider
2304        + crate::client::DebugExt
2305        + Clone
2306        + WasmCompatSend
2307        + WasmCompatSync
2308        + 'static,
2309    H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
2310{
2311    // OpenAI Chat Completions *defers* `response_format` while tools are present
2312    // and no tool result exists yet (see `should_apply_response_format`), then
2313    // applies it once a tool result is in the history. So the native constraint
2314    // does not suppress tool calls — they compose — which is what this flag
2315    // governs. (Caveat: a turn-1 answer with no tool call is therefore not
2316    // schema-constrained; `Native` is "guaranteed" only once tools have run.)
2317    // See issue #1928.
2318    fn capabilities(&self) -> completion::ProviderCapabilities {
2319        // Providers that drop `output_schema` (SUPPORTS_RESPONSE_FORMAT =
2320        // false) cannot compose native structured output with tools; the
2321        // agent then falls back to tool-mode enforcement as their
2322        // pre-migration hand-rolled models did.
2323        completion::ProviderCapabilities::default()
2324            .with_native_output_tool_composition(Ext::SUPPORTS_RESPONSE_FORMAT)
2325    }
2326
2327    async fn completion(
2328        &self,
2329        completion_request: CoreCompletionRequest,
2330    ) -> Result<completion::CompletionResponse, CompletionError> {
2331        // Capture before `normalize` consumes the raw value.
2332        let (response, provider_request_id) = self
2333            .raw_completion_with_request_id(completion_request)
2334            .await?;
2335        let captured = serde_json::to_value(&response)?;
2336        Ok(response
2337            .normalize(Ext::PROVIDER_NAME)?
2338            .with_optional_provider_request_id(provider_request_id)
2339            .with_raw(captured))
2340    }
2341
2342    async fn stream(
2343        &self,
2344        request: CoreCompletionRequest,
2345    ) -> Result<crate::streaming::StreamingCompletionResponse, CompletionError> {
2346        GenericCompletionModel::stream(self, request).await
2347    }
2348}
2349
2350impl<Ext, H> crate::client::ConstructCompletionModel<crate::client::Client<Ext, H>>
2351    for GenericCompletionModel<Ext, H>
2352where
2353    crate::client::Client<Ext, H>: std::fmt::Debug + Clone + 'static,
2354    Ext: crate::client::Provider + Clone + 'static,
2355{
2356    fn construct(client: &crate::client::Client<Ext, H>, model: String) -> Self {
2357        Self::new(client.clone(), model)
2358    }
2359}
2360
2361fn serialize_assistant_content_vec<S>(
2362    value: &[AssistantContent],
2363    serializer: S,
2364) -> Result<S::Ok, S::Error>
2365where
2366    S: Serializer,
2367{
2368    if value.is_empty() {
2369        serializer.serialize_str("")
2370    } else {
2371        value.serialize(serializer)
2372    }
2373}
2374
2375#[cfg(test)]
2376mod tests {
2377    /// The shared chat-completions response type is deliberately lenient about
2378    /// envelope metadata: `object`, `created`, `choices[].index`, and
2379    /// `choices[].finish_reason` may be missing or explicit `null` (lossy
2380    /// OpenAI-compatible gateways and Copilot's multi-vendor chat route both
2381    /// rely on this). An empty `finish_reason` normalizes to `None` rather
2382    /// than erroring. This pins that contract next to the type itself.
2383    #[test]
2384    fn completion_response_tolerates_null_or_missing_envelope_metadata() {
2385        let json = r#"{
2386            "id": "chatcmpl-1",
2387            "object": null,
2388            "created": null,
2389            "model": "some-model",
2390            "choices": [{
2391                "index": null,
2392                "message": { "role": "assistant", "content": "hi" },
2393                "finish_reason": null
2394            }]
2395        }"#;
2396        let response: super::CompletionResponse =
2397            serde_json::from_str(json).expect("null envelope metadata should deserialize");
2398        assert_eq!(response.object, "");
2399        assert_eq!(response.created, 0);
2400        assert_eq!(response.choices[0].index, 0);
2401        assert_eq!(response.choices[0].finish_reason, "");
2402    }
2403
2404    /// Boundary-minted tool ids (`tool-{index}`, from id-less streamed calls)
2405    /// replay to the chat wire as a self-consistent pair: the assistant
2406    /// message's `tool_calls[].id` and the tool result's `tool_call_id` carry
2407    /// the same minted value. The wire requires both fields, so gating minted
2408    /// ids out (the Responses reasoning treatment) is impossible here — and
2409    /// unnecessary: a gateway that omitted ids has no server-side id to
2410    /// validate against, so the consistent pair is accepted. This pins the
2411    /// per-wire upstream rule documented on `SyntheticIds`.
2412    #[test]
2413    fn minted_tool_ids_replay_as_a_consistent_pair() {
2414        let assistant = crate::message::Message::Assistant {
2415            id: None,
2416            content: vec![crate::message::AssistantContent::tool_call(
2417                "tool-0",
2418                "get_weather",
2419                serde_json::json!({"city": "Tokyo"}),
2420            )],
2421        };
2422        let tool_result = crate::message::Message::User {
2423            content: vec![crate::message::UserContent::tool_result(
2424                "tool-0",
2425                "get_weather",
2426                vec![crate::message::ToolResultContent::text("22C")],
2427            )],
2428        };
2429
2430        let assistant_wire: Vec<super::Message> = assistant.try_into().expect("assistant converts");
2431        let result_wire: Vec<super::Message> =
2432            tool_result.try_into().expect("tool result converts");
2433
2434        let call_id = assistant_wire
2435            .iter()
2436            .find_map(|message| match message {
2437                super::Message::Assistant { tool_calls, .. } => {
2438                    tool_calls.first().map(|call| call.id.clone())
2439                }
2440                _ => None,
2441            })
2442            .expect("assistant message carries the tool call");
2443        let result_id = result_wire
2444            .iter()
2445            .find_map(|message| match message {
2446                super::Message::ToolResult { tool_call_id, .. } => Some(tool_call_id.clone()),
2447                _ => None,
2448            })
2449            .expect("tool result message present");
2450
2451        assert_eq!(call_id, "tool-0");
2452        assert_eq!(
2453            result_id, call_id,
2454            "the minted pair must be self-consistent"
2455        );
2456    }
2457
2458    use super::*;
2459    use crate::completion::CompletionRequestBuilder;
2460    use crate::telemetry::ProviderResponseExt;
2461    use crate::test_utils::MockCompletionModel;
2462    use serde_json::{Value, json};
2463    use std::collections::HashMap;
2464
2465    fn test_document(id: &str, text: &str) -> crate::completion::Document {
2466        crate::completion::Document {
2467            id: id.to_string(),
2468            text: text.to_string(),
2469            additional_props: HashMap::new(),
2470        }
2471    }
2472
2473    fn request_with_multi_block_tool_result() -> CoreCompletionRequest {
2474        let tool_result = message::ToolResult {
2475            call: message::ToolCallId::new_or_mint("call-id"),
2476            provider: message::ProviderCallId::new("call-id"),
2477            name: "tool".to_string(),
2478            content: vec![
2479                message::ToolResultContent::text("first"),
2480                message::ToolResultContent::text("second"),
2481            ],
2482        };
2483
2484        CoreCompletionRequest {
2485            model: None,
2486            preamble: None,
2487            chat_history: vec![message::Message::User {
2488                content: vec![message::UserContent::ToolResult(tool_result)],
2489            }],
2490            documents: vec![],
2491            tools: vec![],
2492            temperature: None,
2493            max_tokens: None,
2494            tool_choice: None,
2495            additional_params: None,
2496            output_schema: None,
2497            record_telemetry_content: false,
2498        }
2499    }
2500
2501    #[test]
2502    fn mixed_user_content_preserves_order_around_tool_results() {
2503        let content = vec![
2504            message::UserContent::text("before"),
2505            message::UserContent::tool_result_with_call_id(
2506                "result-id",
2507                "call-id".to_string(),
2508                "tool",
2509                vec![message::ToolResultContent::text("tool output")],
2510            ),
2511            message::UserContent::text("after"),
2512        ];
2513
2514        let messages = user_content_to_messages(content).expect("message conversion");
2515
2516        assert!(matches!(
2517            messages.as_slice(),
2518            [
2519                Message::User { content: before, .. },
2520                Message::ToolResult { tool_call_id, .. },
2521                Message::User { content: after, .. },
2522            ] if matches!(before.first(), Some(UserContent::Text { text }) if text == "before")
2523                && tool_call_id == "call-id"
2524                && matches!(after.first(), Some(UserContent::Text { text }) if text == "after")
2525        ));
2526    }
2527
2528    #[test]
2529    fn video_data_uri_with_unrecognized_mime_round_trips_as_url() {
2530        let original = "data:video/quicktime;base64,AAAA";
2531        let openai_content = UserContent::Video {
2532            video_url: VideoUrl {
2533                url: original.to_string(),
2534            },
2535        };
2536
2537        let rig_content: message::UserContent = openai_content.into();
2538        // Unrecognized MIME: kept as a URL source, not decomposed.
2539        assert!(matches!(
2540            &rig_content,
2541            message::UserContent::Video(video)
2542                if matches!(&video.data, message::DocumentSourceKind::Url(url) if url == original)
2543        ));
2544
2545        let back = UserContent::try_from(rig_content).expect("video should convert back");
2546        assert!(matches!(
2547            back,
2548            UserContent::Video { video_url } if video_url.url == original
2549        ));
2550    }
2551
2552    #[test]
2553    fn video_data_uri_with_known_mime_decomposes_to_base64() {
2554        let openai_content = UserContent::Video {
2555            video_url: VideoUrl {
2556                url: "data:video/mp4;base64,AAAA".to_string(),
2557            },
2558        };
2559
2560        let rig_content: message::UserContent = openai_content.into();
2561        assert!(matches!(
2562            &rig_content,
2563            message::UserContent::Video(video)
2564                if video.media_type == Some(crate::message::VideoMediaType::MP4)
2565                    && matches!(&video.data, message::DocumentSourceKind::Base64(data) if data == "AAAA")
2566        ));
2567    }
2568
2569    #[test]
2570    fn sanitize_plain_text_history_strips_tool_exchange_and_keeps_alternation() {
2571        let mut messages = vec![
2572            serde_json::json!({"role": "user", "content": "Look up the label."}),
2573            serde_json::json!({"role": "assistant", "tool_calls": [
2574                {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
2575            ]}),
2576            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "crimson"}),
2577            serde_json::json!({
2578                "role": "assistant",
2579                "content": [{"type": "text", "text": "The label is crimson."}],
2580                "reasoning_content": "thinking"
2581            }),
2582            serde_json::json!({"role": "user", "content": "Thanks!"}),
2583        ];
2584
2585        sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2586
2587        let roles = messages
2588            .iter()
2589            .map(|m| m["role"].as_str().unwrap_or_default())
2590            .collect::<Vec<_>>();
2591        // tool message removed, tool-call-only assistant dropped, no
2592        // consecutive assistants left.
2593        assert_eq!(roles, ["user", "assistant", "user"]);
2594        assert_eq!(messages[1]["content"], "The label is crimson.");
2595        assert!(messages[1].get("reasoning_content").is_none());
2596        assert!(messages[1].get("tool_calls").is_none());
2597    }
2598
2599    #[test]
2600    fn sanitize_plain_text_history_merges_consecutive_user_messages() {
2601        // Dropping a tool exchange whose final assistant answer never made it
2602        // into history leaves user/user adjacency, which alternation-strict
2603        // APIs reject.
2604        let mut messages = vec![
2605            serde_json::json!({"role": "user", "content": "Look it up."}),
2606            serde_json::json!({"role": "assistant", "tool_calls": [
2607                {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
2608            ]}),
2609            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "crimson"}),
2610            serde_json::json!({"role": "user", "content": "Ask again."}),
2611        ];
2612
2613        sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2614
2615        assert_eq!(messages.len(), 1);
2616        assert_eq!(messages[0]["role"], "user");
2617        assert_eq!(messages[0]["content"], "Look it up.\nAsk again.");
2618    }
2619
2620    #[test]
2621    fn flatten_text_content_parts_treats_refusals_as_text() {
2622        let mut content = serde_json::json!([
2623            {"type": "text", "text": "Partly:"},
2624            {"type": "refusal", "refusal": "I cannot help with that."}
2625        ]);
2626
2627        flatten_text_content_parts(&mut content, "\n", true);
2628
2629        assert_eq!(content, "Partly:\nI cannot help with that.");
2630    }
2631
2632    #[test]
2633    fn sanitize_plain_text_history_merges_consecutive_assistant_messages() {
2634        let mut messages = vec![
2635            serde_json::json!({"role": "assistant", "content": "First."}),
2636            serde_json::json!({"role": "tool", "tool_call_id": "c", "content": "x"}),
2637            serde_json::json!({"role": "assistant", "content": "Second."}),
2638        ];
2639
2640        sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2641
2642        assert_eq!(messages.len(), 1);
2643        assert_eq!(messages[0]["content"], "First.\nSecond.");
2644    }
2645
2646    #[test]
2647    fn tool_result_array_content_preserves_multiple_text_blocks() {
2648        let request = CompletionRequest::try_from(OpenAIRequestParams {
2649            model: "gpt-4o-mini".to_string(),
2650            request: request_with_multi_block_tool_result(),
2651            strict_tools: false,
2652            tool_result_array_content: true,
2653            supports_response_format: true,
2654            supports_tools: true,
2655        })
2656        .expect("request conversion should succeed");
2657
2658        let wire = serde_json::to_value(&request.messages).expect("messages should serialize");
2659
2660        assert_eq!(
2661            wire,
2662            serde_json::json!([
2663                {
2664                    "role": "tool",
2665                    "tool_call_id": "call-id",
2666                    "content": [
2667                        {
2668                            "type": "text",
2669                            "text": "first"
2670                        },
2671                        {
2672                            "type": "text",
2673                            "text": "second"
2674                        }
2675                    ]
2676                }
2677            ])
2678        );
2679    }
2680
2681    #[test]
2682    fn tool_result_string_content_flattens_multiple_text_blocks() {
2683        let request = CompletionRequest::try_from(OpenAIRequestParams {
2684            model: "gpt-4o-mini".to_string(),
2685            request: request_with_multi_block_tool_result(),
2686            strict_tools: false,
2687            tool_result_array_content: false,
2688            supports_response_format: true,
2689            supports_tools: true,
2690        })
2691        .expect("request conversion should succeed");
2692
2693        let wire = serde_json::to_value(&request.messages).expect("messages should serialize");
2694
2695        assert_eq!(
2696            wire,
2697            serde_json::json!([
2698                {
2699                    "role": "tool",
2700                    "tool_call_id": "call-id",
2701                    "content": "first\nsecond"
2702                }
2703            ])
2704        );
2705    }
2706
2707    #[test]
2708    fn multiple_tool_result_blocks_convert_to_distinct_content_parts() {
2709        let result = message::ToolResult {
2710            call: message::ToolCallId::new_or_mint("call-id"),
2711            name: "tool".to_string(),
2712            provider: message::ProviderCallId::new("call-id"),
2713            content: vec![
2714                message::ToolResultContent::text("first"),
2715                message::ToolResultContent::json(serde_json::json!({
2716                    "status": "ok"
2717                })),
2718                message::ToolResultContent::text("second"),
2719            ],
2720        };
2721
2722        let converted = Message::try_from(result).expect("tool result should convert");
2723
2724        assert_eq!(
2725            converted,
2726            Message::ToolResult {
2727                tool_call_id: "call-id".to_string(),
2728                content: ToolResultContentValue::Array(vec![
2729                    ToolResultContent::from("first".to_string()),
2730                    ToolResultContent::from(r#"{"status":"ok"}"#.to_string()),
2731                    ToolResultContent::from("second".to_string()),
2732                ]),
2733            }
2734        );
2735    }
2736
2737    #[test]
2738    fn test_openai_request_uses_request_model_override() {
2739        let request = crate::completion::CompletionRequest {
2740            model: Some("gpt-4.1".to_string()),
2741            preamble: None,
2742            chat_history: vec!["Hello".into()],
2743            documents: vec![],
2744            tools: vec![],
2745            temperature: None,
2746            max_tokens: None,
2747            tool_choice: None,
2748            additional_params: None,
2749            output_schema: None,
2750            record_telemetry_content: false,
2751        };
2752
2753        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2754            model: "gpt-4o-mini".to_string(),
2755            request,
2756            strict_tools: false,
2757            tool_result_array_content: false,
2758            supports_response_format: true,
2759            supports_tools: true,
2760        })
2761        .expect("request conversion should succeed");
2762        let serialized =
2763            serde_json::to_value(openai_request).expect("serialization should succeed");
2764
2765        assert_eq!(serialized["model"], "gpt-4.1");
2766    }
2767
2768    #[test]
2769    fn test_openai_request_uses_default_model_when_override_unset() {
2770        let request = crate::completion::CompletionRequest {
2771            model: None,
2772            preamble: None,
2773            chat_history: vec!["Hello".into()],
2774            documents: vec![],
2775            tools: vec![],
2776            temperature: None,
2777            max_tokens: None,
2778            tool_choice: None,
2779            additional_params: None,
2780            output_schema: None,
2781            record_telemetry_content: false,
2782        };
2783
2784        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2785            model: "gpt-4o-mini".to_string(),
2786            request,
2787            strict_tools: false,
2788            tool_result_array_content: false,
2789            supports_response_format: true,
2790            supports_tools: true,
2791        })
2792        .expect("request conversion should succeed");
2793        let serialized =
2794            serde_json::to_value(openai_request).expect("serialization should succeed");
2795
2796        assert_eq!(serialized["model"], "gpt-4o-mini");
2797    }
2798
2799    #[test]
2800    fn openai_chat_request_keeps_documents_after_system_messages() {
2801        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
2802            .message(crate::completion::Message::system("System prompt"))
2803            .message(crate::completion::Message::user("Earlier user turn"))
2804            .message(crate::completion::Message::assistant(
2805                "Earlier assistant turn",
2806            ))
2807            .document(test_document("doc1", "Document text."))
2808            .build();
2809
2810        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2811            model: "gpt-4o-mini".to_string(),
2812            request,
2813            strict_tools: false,
2814            tool_result_array_content: false,
2815            supports_response_format: true,
2816            supports_tools: true,
2817        })
2818        .expect("request conversion should succeed");
2819
2820        let serialized =
2821            serde_json::to_value(&openai_request.messages).expect("messages should serialize");
2822        let messages = serialized.as_array().expect("messages should be an array");
2823
2824        assert_eq!(messages.len(), 5);
2825        assert_eq!(messages[0]["role"], "system");
2826        assert_eq!(messages[1]["role"], "user");
2827        assert!(
2828            messages[1].to_string().contains("<file id: doc1>"),
2829            "document message should follow system message: {messages:?}"
2830        );
2831        assert_eq!(messages[2]["role"], "user");
2832        assert!(
2833            messages[2].to_string().contains("Earlier user turn"),
2834            "prior user history should follow document message: {messages:?}"
2835        );
2836        assert_eq!(messages[3]["role"], "assistant");
2837        assert!(
2838            messages[3].to_string().contains("Earlier assistant turn"),
2839            "prior assistant history should follow prior user history: {messages:?}"
2840        );
2841        assert_eq!(messages[4]["role"], "user");
2842        assert!(
2843            messages[4].to_string().contains("Prompt"),
2844            "prompt should remain last: {messages:?}"
2845        );
2846    }
2847
2848    #[test]
2849    fn openai_chat_direct_request_keeps_documents_after_system_messages() {
2850        let request = CoreCompletionRequest {
2851            model: None,
2852            preamble: None,
2853            chat_history: vec![
2854                crate::completion::Message::system("System prompt"),
2855                crate::completion::Message::assistant("Earlier assistant turn"),
2856                crate::completion::Message::system("Mid-conversation instruction"),
2857                crate::completion::Message::user("Prompt"),
2858            ],
2859            documents: vec![test_document("doc1", "Document text.")],
2860            tools: vec![],
2861            temperature: None,
2862            max_tokens: None,
2863            tool_choice: None,
2864            additional_params: None,
2865            output_schema: None,
2866            record_telemetry_content: false,
2867        };
2868
2869        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2870            model: "gpt-4o-mini".to_string(),
2871            request,
2872            strict_tools: false,
2873            tool_result_array_content: false,
2874            supports_response_format: true,
2875            supports_tools: true,
2876        })
2877        .expect("request conversion should succeed");
2878
2879        let serialized =
2880            serde_json::to_value(&openai_request.messages).expect("messages should serialize");
2881        let messages = serialized.as_array().expect("messages should be an array");
2882
2883        assert_eq!(messages.len(), 5);
2884        assert_eq!(messages[0]["role"], "system");
2885        assert_eq!(messages[1]["role"], "user");
2886        assert!(
2887            messages[1].to_string().contains("<file id: doc1>"),
2888            "document message should follow leading system messages: {messages:?}"
2889        );
2890        assert_eq!(messages[2]["role"], "assistant");
2891        assert_eq!(messages[3]["role"], "system");
2892        assert_eq!(messages[4]["role"], "user");
2893        assert_eq!(
2894            messages
2895                .iter()
2896                .filter(|message| message.to_string().contains("<file id: doc1>"))
2897                .count(),
2898            1,
2899            "document message should appear exactly once: {messages:?}"
2900        );
2901    }
2902
2903    #[test]
2904    fn assistant_reasoning_alone_is_dropped() {
2905        let assistant_content = vec![message::AssistantContent::reasoning("hidden")];
2906
2907        let converted: Vec<Message> =
2908            assistant_content_to_messages(assistant_content).expect("conversion should work");
2909
2910        assert!(converted.is_empty());
2911    }
2912
2913    // Regression test: providers that serve thinking models over the OpenAI
2914    // Chat Completions schema (DeepSeek-R1, GLM-4.6, Qwen3-Thinking) return
2915    // 400 "thinking is enabled but reasoning_content is missing" on the next
2916    // turn if the prior assistant tool-call message didn't echo the reasoning.
2917    #[test]
2918    fn assistant_reasoning_is_attached_to_tool_call_message() {
2919        let assistant_content = vec![
2920            message::AssistantContent::reasoning("hidden"),
2921            message::AssistantContent::text("visible"),
2922            message::AssistantContent::tool_call(
2923                "call_1",
2924                "subtract",
2925                serde_json::json!({"x": 2, "y": 1}),
2926            ),
2927        ];
2928
2929        let converted: Vec<Message> =
2930            assistant_content_to_messages(assistant_content).expect("conversion should work");
2931        assert_eq!(converted.len(), 1);
2932
2933        match &converted[0] {
2934            Message::Assistant {
2935                content,
2936                tool_calls,
2937                reasoning,
2938                ..
2939            } => {
2940                assert_eq!(
2941                    content,
2942                    &vec![AssistantContent::Text {
2943                        text: "visible".to_string()
2944                    }]
2945                );
2946                assert_eq!(tool_calls.len(), 1);
2947                assert_eq!(tool_calls[0].id, "call_1");
2948                assert_eq!(tool_calls[0].function.name, "subtract");
2949                assert_eq!(
2950                    tool_calls[0].function.arguments,
2951                    serde_json::json!({"x": 2, "y": 1})
2952                );
2953                assert_eq!(reasoning.as_deref(), Some("hidden"));
2954            }
2955            _ => panic!("expected assistant message"),
2956        }
2957
2958        let json = serde_json::to_value(&converted[0]).expect("serialize");
2959        assert_eq!(json["reasoning_content"], "hidden");
2960    }
2961
2962    #[test]
2963    fn assistant_reasoning_roundtrips_back_to_rig_message() {
2964        let assistant = Message::Assistant {
2965            content: vec![AssistantContent::Text {
2966                text: "visible".to_string(),
2967            }],
2968            reasoning: Some("hidden".to_string()),
2969            refusal: None,
2970            audio: None,
2971            name: None,
2972            tool_calls: vec![],
2973            reasoning_details: vec![],
2974            images: vec![],
2975        };
2976
2977        let rig_msg: message::Message = assistant.try_into().expect("convert back");
2978
2979        let message::Message::Assistant { content, .. } = rig_msg else {
2980            panic!("expected assistant");
2981        };
2982
2983        let items: Vec<_> = content.into_iter().collect();
2984        assert_eq!(items.len(), 2);
2985        assert!(matches!(items[0], message::AssistantContent::Reasoning(_)));
2986        assert!(matches!(items[1], message::AssistantContent::Text(_)));
2987    }
2988
2989    #[test]
2990    fn provider_response_text_response_reads_assistant_multipart_output() {
2991        let response = CompletionResponse {
2992            id: "resp_123".to_owned(),
2993            object: "chat.completion".to_owned(),
2994            created: 0,
2995            model: GPT_4O.to_owned(),
2996            system_fingerprint: None,
2997            service_tier: None,
2998            choices: vec![Choice {
2999                index: 0,
3000                message: Message::Assistant {
3001                    content: vec![
3002                        AssistantContent::Text {
3003                            text: "first".to_owned(),
3004                        },
3005                        AssistantContent::Refusal {
3006                            refusal: "second".to_owned(),
3007                        },
3008                        AssistantContent::Text {
3009                            text: "third".to_owned(),
3010                        },
3011                    ],
3012                    reasoning: Some("hidden".to_owned()),
3013                    refusal: None,
3014                    audio: None,
3015                    name: None,
3016                    tool_calls: vec![],
3017                    reasoning_details: vec![],
3018                    images: vec![],
3019                },
3020                logprobs: None,
3021                finish_reason: "stop".to_owned(),
3022            }],
3023            usage: None,
3024        };
3025
3026        assert_eq!(
3027            response.get_text_response(),
3028            Some("first\nsecond\nthird".to_owned())
3029        );
3030    }
3031
3032    #[test]
3033    fn raw_completion_response_retains_service_tier() {
3034        let response: CompletionResponse = serde_json::from_value(json!({
3035            "id": "chatcmpl-tier",
3036            "object": "chat.completion",
3037            "created": 0,
3038            "model": GPT_4O,
3039            "system_fingerprint": "fp_test",
3040            "service_tier": "priority",
3041            "choices": [{
3042                "index": 0,
3043                "message": {"role": "assistant", "content": "ok"},
3044                "finish_reason": "stop"
3045            }]
3046        }))
3047        .expect("live Chat Completions metadata should deserialize");
3048
3049        assert_eq!(response.service_tier.as_deref(), Some("priority"));
3050    }
3051
3052    #[test]
3053    fn provider_response_text_response_falls_back_to_assistant_refusal_field() {
3054        let response = CompletionResponse {
3055            id: "resp_123".to_owned(),
3056            object: "chat.completion".to_owned(),
3057            created: 0,
3058            model: GPT_4O.to_owned(),
3059            system_fingerprint: None,
3060            service_tier: None,
3061            choices: vec![Choice {
3062                index: 0,
3063                message: Message::Assistant {
3064                    content: vec![],
3065                    reasoning: None,
3066                    refusal: Some("blocked".to_owned()),
3067                    audio: None,
3068                    name: None,
3069                    tool_calls: vec![],
3070                    reasoning_details: vec![],
3071                    images: vec![],
3072                },
3073                logprobs: None,
3074                finish_reason: "stop".to_owned(),
3075            }],
3076            usage: None,
3077        };
3078
3079        assert_eq!(response.get_text_response(), Some("blocked".to_owned()));
3080    }
3081
3082    /// One chat-completions turn, built from the wire shape a structured-output
3083    /// refusal actually has (`content: null` beside a top-level `refusal`).
3084    fn refusal_response(body: Value) -> CompletionResponse {
3085        serde_json::from_value(json!({
3086            "id": "chatcmpl-refusal",
3087            "object": "chat.completion",
3088            "created": 0,
3089            "model": GPT_4O,
3090            "choices": [{ "index": 0, "message": body, "finish_reason": "stop" }],
3091        }))
3092        .expect("the refusal wire shape must deserialize")
3093    }
3094
3095    fn normalized_text(response: CompletionResponse) -> Vec<completion::AssistantContent> {
3096        use crate::completion::NormalizeCompletionResponse;
3097
3098        response
3099            .normalize("openai")
3100            .expect("a refusal turn must normalize")
3101            .choice
3102    }
3103
3104    #[test]
3105    fn refusal_sibling_of_null_content_becomes_assistant_text() {
3106        let response = refusal_response(json!({
3107            "role": "assistant",
3108            "content": null,
3109            "refusal": "I'm sorry, I can't help with that."
3110        }));
3111
3112        assert_eq!(
3113            normalized_text(response),
3114            vec![completion::AssistantContent::text(
3115                "I'm sorry, I can't help with that."
3116            )]
3117        );
3118    }
3119
3120    /// The raw text view and the normalized response must not disagree about
3121    /// whether the turn said anything — the disagreement was the bug.
3122    #[test]
3123    fn refusal_raw_and_normalized_views_agree() {
3124        let message = json!({
3125            "role": "assistant",
3126            "content": null,
3127            "refusal": "I'm sorry, I can't help with that."
3128        });
3129        let raw_text = refusal_response(message.clone())
3130            .get_text_response()
3131            .expect("raw text view");
3132
3133        assert_eq!(
3134            normalized_text(refusal_response(message)),
3135            vec![completion::AssistantContent::text(raw_text)]
3136        );
3137    }
3138
3139    /// Content wins: the fallback only fires when the parts carry nothing, so a
3140    /// turn with both never duplicates its text.
3141    #[test]
3142    fn refusal_beside_non_empty_content_does_not_duplicate() {
3143        let response = refusal_response(json!({
3144            "role": "assistant",
3145            "content": "here is the answer",
3146            "refusal": "I'm sorry, I can't help with that."
3147        }));
3148
3149        assert_eq!(
3150            normalized_text(response),
3151            vec![completion::AssistantContent::text("here is the answer")]
3152        );
3153    }
3154
3155    /// An empty `refusal` is not content: the turn stays an empty-response
3156    /// error rather than gaining a fabricated empty text block.
3157    #[test]
3158    fn empty_refusal_is_not_content() {
3159        use crate::completion::NormalizeCompletionResponse;
3160
3161        let response = refusal_response(json!({
3162            "role": "assistant",
3163            "content": null,
3164            "refusal": ""
3165        }));
3166
3167        assert!(response.normalize("openai").is_err());
3168    }
3169
3170    /// A refusal beside tool calls keeps both — the fallback is about the
3171    /// message's *text*, and tool calls are appended as before.
3172    #[test]
3173    fn refusal_beside_tool_calls_keeps_both() {
3174        let response = refusal_response(json!({
3175            "role": "assistant",
3176            "content": null,
3177            "refusal": "I'm sorry, I can't help with that.",
3178            "tool_calls": [{
3179                "id": "call_1",
3180                "type": "function",
3181                "function": { "name": "lookup", "arguments": "{}" }
3182            }]
3183        }));
3184
3185        let content = normalized_text(response);
3186        assert_eq!(content.len(), 2);
3187        assert_eq!(
3188            content.first(),
3189            Some(&completion::AssistantContent::text(
3190                "I'm sorry, I can't help with that."
3191            ))
3192        );
3193        assert!(matches!(
3194            content.get(1),
3195            Some(completion::AssistantContent::ToolCall(_))
3196        ));
3197    }
3198
3199    /// The Responses-shaped `refusal` **content part** is not what chat
3200    /// completions sends, but the model still accepts it — and it must not
3201    /// also trigger the sibling fallback.
3202    #[test]
3203    fn refusal_content_part_still_maps_to_text_without_the_fallback() {
3204        let response = refusal_response(json!({
3205            "role": "assistant",
3206            "content": [{ "type": "refusal", "refusal": "part refusal" }],
3207            "refusal": "sibling refusal"
3208        }));
3209
3210        assert_eq!(
3211            normalized_text(response),
3212            vec![completion::AssistantContent::text("part refusal")]
3213        );
3214    }
3215
3216    /// The history round trip: a stored refusal-only assistant message used to
3217    /// fail conversion outright.
3218    #[test]
3219    fn refusal_only_message_converts_into_rig_history() {
3220        let wire: Message = serde_json::from_value(json!({
3221            "role": "assistant",
3222            "content": null,
3223            "refusal": "I'm sorry, I can't help with that."
3224        }))
3225        .expect("wire message");
3226
3227        let converted = message::Message::try_from(wire).expect("history conversion");
3228
3229        assert_eq!(
3230            converted,
3231            message::Message::Assistant {
3232                id: None,
3233                content: vec![message::AssistantContent::text(
3234                    "I'm sorry, I can't help with that."
3235                )],
3236            }
3237        );
3238    }
3239
3240    /// `"content": ""` decodes to a *present but empty* text part, so the
3241    /// fallback and the parts must be either/or: appending both would put an
3242    /// empty text block back on the wire beside the refusal and make this view
3243    /// of the message disagree with the one `normalize` builds.
3244    #[test]
3245    fn refusal_beside_an_empty_content_string_converts_to_the_refusal_alone() {
3246        let wire: Message = serde_json::from_value(json!({
3247            "role": "assistant",
3248            "content": "",
3249            "refusal": "I'm sorry, I can't help with that."
3250        }))
3251        .expect("wire message");
3252
3253        let converted = message::Message::try_from(wire).expect("history conversion");
3254
3255        assert_eq!(
3256            converted,
3257            message::Message::Assistant {
3258                id: None,
3259                content: vec![message::AssistantContent::text(
3260                    "I'm sorry, I can't help with that."
3261                )],
3262            },
3263            "the empty part must not ride along beside the refusal"
3264        );
3265    }
3266
3267    /// The other side of that branch: content that carries text keeps every
3268    /// part, and the refusal is not appended.
3269    #[test]
3270    fn refusal_beside_real_content_converts_to_the_content_alone() {
3271        let wire: Message = serde_json::from_value(json!({
3272            "role": "assistant",
3273            "content": "here is the answer",
3274            "refusal": "I'm sorry, I can't help with that."
3275        }))
3276        .expect("wire message");
3277
3278        let converted = message::Message::try_from(wire).expect("history conversion");
3279
3280        assert_eq!(
3281            converted,
3282            message::Message::Assistant {
3283                id: None,
3284                content: vec![message::AssistantContent::text("here is the answer")],
3285            }
3286        );
3287    }
3288
3289    #[test]
3290    fn refusal_only_message_with_empty_refusal_still_fails_conversion() {
3291        let wire: Message = serde_json::from_value(json!({
3292            "role": "assistant",
3293            "content": null,
3294            "refusal": ""
3295        }))
3296        .expect("wire message");
3297
3298        assert!(message::Message::try_from(wire).is_err());
3299    }
3300
3301    #[test]
3302    fn test_max_tokens_is_forwarded_to_request() {
3303        let request = crate::completion::CompletionRequest {
3304            model: None,
3305            preamble: None,
3306            chat_history: vec!["Hello".into()],
3307            documents: vec![],
3308            tools: vec![],
3309            temperature: None,
3310            max_tokens: Some(4096),
3311            tool_choice: None,
3312            additional_params: None,
3313            output_schema: None,
3314            record_telemetry_content: false,
3315        };
3316
3317        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3318            model: "gpt-4o-mini".to_string(),
3319            request,
3320            strict_tools: false,
3321            tool_result_array_content: false,
3322            supports_response_format: true,
3323            supports_tools: true,
3324        })
3325        .expect("request conversion should succeed");
3326        let serialized =
3327            serde_json::to_value(openai_request).expect("serialization should succeed");
3328
3329        assert_eq!(serialized["max_tokens"], 4096);
3330    }
3331
3332    /// A chat-completions request whose only interesting property is the cap.
3333    fn capped_request(
3334        max_tokens: Option<u64>,
3335        additional_params: Option<Value>,
3336    ) -> CompletionRequest {
3337        CompletionRequest::try_from(OpenAIRequestParams {
3338            model: "gpt-4o-mini".to_string(),
3339            request: crate::completion::CompletionRequest {
3340                model: None,
3341                preamble: None,
3342                chat_history: vec!["Hello".into()],
3343                documents: vec![],
3344                tools: vec![],
3345                temperature: None,
3346                max_tokens,
3347                tool_choice: None,
3348                additional_params,
3349                output_schema: None,
3350                record_telemetry_content: false,
3351            },
3352            strict_tools: false,
3353            tool_result_array_content: false,
3354            supports_response_format: true,
3355            supports_tools: true,
3356        })
3357        .expect("request conversion should succeed")
3358    }
3359
3360    #[test]
3361    fn request_body_keeps_the_legacy_cap_when_the_endpoint_wants_it() {
3362        let body =
3363            request_body(&capped_request(Some(4096), None), false).expect("body should serialize");
3364
3365        assert_eq!(body["max_tokens"], 4096);
3366        assert!(body.get("max_completion_tokens").is_none());
3367    }
3368
3369    #[test]
3370    fn request_body_renames_the_cap_for_the_modern_endpoint() {
3371        let body =
3372            request_body(&capped_request(Some(4096), None), true).expect("body should serialize");
3373
3374        assert_eq!(body["max_completion_tokens"], 4096);
3375        assert!(
3376            body.get("max_tokens").is_none(),
3377            "the legacy key must leave the body: reasoning models reject its presence"
3378        );
3379    }
3380
3381    #[test]
3382    fn request_body_without_a_cap_carries_neither_spelling() {
3383        let body = request_body(&capped_request(None, None), true).expect("body should serialize");
3384
3385        assert!(body.get("max_tokens").is_none());
3386        assert!(body.get("max_completion_tokens").is_none());
3387    }
3388
3389    #[test]
3390    fn request_body_keeps_a_caller_supplied_modern_cap() {
3391        let body = request_body(
3392            &capped_request(Some(4096), Some(json!({ "max_completion_tokens": 48 }))),
3393            true,
3394        )
3395        .expect("body should serialize");
3396
3397        assert_eq!(body["max_completion_tokens"], 48);
3398        assert!(body.get("max_tokens").is_none());
3399    }
3400
3401    #[test]
3402    fn request_body_upgrades_a_caller_supplied_legacy_cap() {
3403        let body = request_body(
3404            &capped_request(None, Some(json!({ "max_tokens": 48 }))),
3405            true,
3406        )
3407        .expect("body should serialize");
3408
3409        assert_eq!(body["max_completion_tokens"], 48);
3410        assert!(body.get("max_tokens").is_none());
3411    }
3412
3413    #[test]
3414    fn request_body_moves_nothing_but_the_cap() {
3415        let request = capped_request(Some(4096), Some(json!({ "top_p": 0.5 })));
3416        let plain = serde_json::to_value(&request).expect("serialization should succeed");
3417        let mut renamed = request_body(&request, true).expect("body");
3418
3419        let cap = renamed
3420            .as_object_mut()
3421            .expect("object body")
3422            .remove("max_completion_tokens")
3423            .expect("renamed cap");
3424        renamed["max_tokens"] = cap;
3425
3426        assert_eq!(renamed, plain);
3427    }
3428
3429    /// The gate itself, over every family whose behavior was measured against
3430    /// the live endpoint: the reasoning models reject the legacy field, and
3431    /// everything else — including OpenAI's own older models and any
3432    /// compatible server's model names — still gets the bytes it always got.
3433    #[test]
3434    fn modern_output_cap_covers_exactly_the_reasoning_families() {
3435        for model in [
3436            "gpt-5",
3437            "gpt-5.1",
3438            "gpt-5.2",
3439            "gpt-5-nano",
3440            "gpt-5-2025-08-07",
3441            "gpt-6",
3442            "o1",
3443            "o1-mini",
3444            "o3",
3445            "o3-mini",
3446            "o4-mini",
3447            "o4-mini-2025-04-16",
3448        ] {
3449            assert!(
3450                is_openai_reasoning_model(model),
3451                "{model} rejects `max_tokens` and must get the modern spelling"
3452            );
3453        }
3454
3455        for model in [
3456            "gpt-4o",
3457            "gpt-4o-mini",
3458            "gpt-4.1",
3459            "gpt-4.1-nano",
3460            "gpt-4-turbo",
3461            "gpt-3.5-turbo",
3462            "chatgpt-4o-latest",
3463            // Compatible-server model names reached through this extension.
3464            "Qwen/Qwen3-4B",
3465            "openai/gpt-oss-20b",
3466            "gpt-oss-120b",
3467            "llama-3.1-8b-instruct",
3468            // Near misses that must not be read as a family or a series.
3469            "gpt-45",
3470            "gpt-",
3471            "o",
3472            "opus",
3473            "o5x",
3474            "",
3475        ] {
3476            assert!(
3477                !is_openai_reasoning_model(model),
3478                "{model:?} still takes `max_tokens`; changing its request would be a regression"
3479            );
3480        }
3481    }
3482
3483    /// The predicate is what the provider extension actually consults.
3484    #[test]
3485    fn openai_extension_asks_for_the_modern_cap_only_on_reasoning_models() {
3486        let ext = super::super::OpenAICompletionsExt::default();
3487
3488        assert!(ext.requires_modern_output_cap("gpt-5-nano"));
3489        assert!(!ext.requires_modern_output_cap(GPT_4O_MINI));
3490    }
3491
3492    #[test]
3493    fn test_max_tokens_omitted_when_none() {
3494        let request = crate::completion::CompletionRequest {
3495            model: None,
3496            preamble: None,
3497            chat_history: vec!["Hello".into()],
3498            documents: vec![],
3499            tools: vec![],
3500            temperature: None,
3501            max_tokens: None,
3502            tool_choice: None,
3503            additional_params: None,
3504            output_schema: None,
3505            record_telemetry_content: false,
3506        };
3507
3508        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3509            model: "gpt-4o-mini".to_string(),
3510            request,
3511            strict_tools: false,
3512            tool_result_array_content: false,
3513            supports_response_format: true,
3514            supports_tools: true,
3515        })
3516        .expect("request conversion should succeed");
3517        let serialized =
3518            serde_json::to_value(openai_request).expect("serialization should succeed");
3519
3520        assert!(serialized.get("max_tokens").is_none());
3521    }
3522
3523    /// A mixed `additional_params.tools` array splits by shape: function tools
3524    /// merge into the typed `tools` field (issue #1890 — left in the flattened
3525    /// params they replace the typed field at serialization), while
3526    /// non-function entries stay behind for the provider's `prepare_request`
3527    /// hook (Groq folds its native tools into `compound_custom` from there).
3528    /// Not a cassette test: OpenAI proper rejects non-function chat tools, so
3529    /// the retained-entry half cannot be recorded against the live API.
3530    #[test]
3531    fn additional_params_function_tools_merge_and_native_tools_stay() {
3532        let request = CoreCompletionRequest {
3533            model: None,
3534            preamble: None,
3535            chat_history: vec!["Hello".into()],
3536            documents: vec![],
3537            tools: vec![crate::completion::ToolDefinition {
3538                name: "builder_tool".to_string(),
3539                description: "from the builder".to_string(),
3540                parameters: serde_json::json!({"type": "object", "properties": {}}),
3541            }],
3542            temperature: None,
3543            max_tokens: None,
3544            tool_choice: None,
3545            additional_params: Some(serde_json::json!({
3546                "tools": [
3547                    {
3548                        "type": "function",
3549                        "function": {
3550                            "name": "params_tool",
3551                            "description": "from additional_params",
3552                            "parameters": {"type": "object", "properties": {}}
3553                        }
3554                    },
3555                    {"type": "browser_search"}
3556                ]
3557            })),
3558            output_schema: None,
3559            record_telemetry_content: false,
3560        };
3561
3562        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3563            model: "gpt-4o-mini".to_string(),
3564            request,
3565            strict_tools: false,
3566            tool_result_array_content: false,
3567            supports_response_format: true,
3568            supports_tools: true,
3569        })
3570        .expect("request conversion should succeed");
3571
3572        let names: Vec<&str> = openai_request
3573            .tools
3574            .iter()
3575            .map(|tool| tool.function.name.as_str())
3576            .collect();
3577        assert_eq!(names, vec!["builder_tool", "params_tool"]);
3578        assert_eq!(
3579            openai_request.additional_params,
3580            Some(serde_json::json!({"tools": [{"type": "browser_search"}]}))
3581        );
3582    }
3583
3584    #[test]
3585    fn request_conversion_errors_when_all_messages_are_filtered() {
3586        let request = CoreCompletionRequest {
3587            model: None,
3588            preamble: None,
3589            chat_history: vec![message::Message::Assistant {
3590                id: None,
3591                content: vec![message::AssistantContent::reasoning("hidden")],
3592            }],
3593            documents: vec![],
3594            tools: vec![],
3595            temperature: None,
3596            max_tokens: None,
3597            tool_choice: None,
3598            additional_params: None,
3599            output_schema: None,
3600            record_telemetry_content: false,
3601        };
3602
3603        let result = CompletionRequest::try_from(OpenAIRequestParams {
3604            model: "gpt-4o-mini".to_string(),
3605            request,
3606            strict_tools: false,
3607            tool_result_array_content: false,
3608            supports_response_format: true,
3609            supports_tools: true,
3610        });
3611
3612        assert!(matches!(result, Err(CompletionError::RequestError(_))));
3613    }
3614
3615    #[test]
3616    fn request_conversion_omits_response_format_on_initial_tool_turn() {
3617        let request = CoreCompletionRequest {
3618            model: None,
3619            preamble: None,
3620            chat_history: vec![message::Message::user(
3621                "Hello, whats the weather in London?",
3622            )],
3623            documents: vec![],
3624            tools: vec![completion::ToolDefinition {
3625                name: "weather".to_string(),
3626                description: "Get the weather".to_string(),
3627                parameters: serde_json::json!({
3628                    "type": "object",
3629                    "properties": {
3630                        "city": { "type": "string" }
3631                    },
3632                    "required": ["city"]
3633                }),
3634            }],
3635            temperature: None,
3636            max_tokens: None,
3637            tool_choice: None,
3638            additional_params: None,
3639            output_schema: Some(
3640                serde_json::from_value(serde_json::json!({
3641                    "title": "WeatherResponse",
3642                    "type": "object",
3643                    "properties": {
3644                        "city": { "type": "string" },
3645                        "weather": { "type": "string" }
3646                    },
3647                    "required": ["city", "weather"]
3648                }))
3649                .expect("schema should deserialize"),
3650            ),
3651            record_telemetry_content: false,
3652        };
3653
3654        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3655            model: "gpt-4o-mini".to_string(),
3656            request,
3657            strict_tools: false,
3658            tool_result_array_content: false,
3659            supports_response_format: true,
3660            supports_tools: true,
3661        })
3662        .expect("request conversion should succeed");
3663
3664        let serialized =
3665            serde_json::to_value(openai_request).expect("serialization should succeed");
3666
3667        assert!(
3668            serialized.get("response_format").is_none(),
3669            "initial tool turn should omit response_format: {serialized:?}"
3670        );
3671    }
3672
3673    #[test]
3674    fn request_conversion_restores_response_format_after_tool_result() {
3675        let request = CoreCompletionRequest {
3676            model: None,
3677            preamble: None,
3678            chat_history: vec![
3679                message::Message::user("Hello, whats the weather in London?"),
3680                message::Message::Assistant {
3681                    id: None,
3682                    content: vec![message::AssistantContent::tool_call(
3683                        "call_1",
3684                        "weather",
3685                        serde_json::json!({ "city": "London" }),
3686                    )],
3687                },
3688                message::Message::tool_result(
3689                    "call_1",
3690                    "weather",
3691                    "The weather in London is all fire and brimstone",
3692                ),
3693            ],
3694            documents: vec![],
3695            tools: vec![completion::ToolDefinition {
3696                name: "weather".to_string(),
3697                description: "Get the weather".to_string(),
3698                parameters: serde_json::json!({
3699                    "type": "object",
3700                    "properties": {
3701                        "city": { "type": "string" }
3702                    },
3703                    "required": ["city"]
3704                }),
3705            }],
3706            temperature: None,
3707            max_tokens: None,
3708            tool_choice: None,
3709            additional_params: None,
3710            output_schema: Some(
3711                serde_json::from_value(serde_json::json!({
3712                    "title": "WeatherResponse",
3713                    "type": "object",
3714                    "properties": {
3715                        "city": { "type": "string" },
3716                        "weather": { "type": "string" }
3717                    },
3718                    "required": ["city", "weather"]
3719                }))
3720                .expect("schema should deserialize"),
3721            ),
3722            record_telemetry_content: false,
3723        };
3724
3725        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
3726            model: "gpt-4o-mini".to_string(),
3727            request,
3728            strict_tools: false,
3729            tool_result_array_content: false,
3730            supports_response_format: true,
3731            supports_tools: true,
3732        })
3733        .expect("request conversion should succeed");
3734
3735        let serialized =
3736            serde_json::to_value(openai_request).expect("serialization should succeed");
3737
3738        assert!(
3739            serialized.get("response_format").is_some(),
3740            "follow-up turn should restore response_format: {serialized:?}"
3741        );
3742    }
3743
3744    #[test]
3745    fn deserialize_llama_cpp_tool_call() {
3746        let request = r#"{
3747            "choices": [{
3748                "finish_reason": "tool_calls",
3749                "index": 0,
3750                "message": {
3751                    "role": "assistant",
3752                    "content": "",
3753                    "tool_calls": [{ "type": "function", "function": { "name": "hello_world", "arguments": { "city": "Paris" } }, "id": "xxx" }]
3754                }
3755            }],
3756            "created": 0,
3757            "model": "gpt-4o-mini",
3758            "system_fingerprint": "fp_xxx",
3759            "object": "chat.completion",
3760            "usage": { "completion_tokens": 13, "prompt_tokens": 255, "total_tokens": 268 },
3761            "id": "xxx"
3762        }
3763        "#;
3764        let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
3765
3766        let ApiResponse::Ok(response) = response else {
3767            panic!("expected successful completion response");
3768        };
3769        assert_eq!(response.choices.len(), 1);
3770
3771        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3772            panic!("expected assistant message");
3773        };
3774        assert_eq!(tool_calls.len(), 1);
3775        assert_eq!(tool_calls[0].id, "xxx");
3776        assert_eq!(tool_calls[0].function.name, "hello_world");
3777        assert_eq!(
3778            tool_calls[0].function.arguments,
3779            serde_json::json!({"city": "Paris"})
3780        );
3781    }
3782
3783    #[test]
3784    fn deserialize_openai_stringified_tool_call() {
3785        let request = r#"{
3786            "choices": [{
3787                "finish_reason": "tool_calls",
3788                "index": 0,
3789                "message": {
3790                    "role": "assistant",
3791                    "content": "",
3792                    "tool_calls": [{ "type": "function", "function": { "name": "hello_world", "arguments": "{\"city\":\"Paris\"}" }, "id": "xxx" }]
3793                }
3794            }],
3795            "created": 0,
3796            "model": "gpt-4o-mini",
3797            "system_fingerprint": "fp_xxx",
3798            "object": "chat.completion",
3799            "usage": { "completion_tokens": 13, "prompt_tokens": 255, "total_tokens": 268 },
3800            "id": "xxx"
3801        }
3802        "#;
3803        let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
3804
3805        let ApiResponse::Ok(response) = response else {
3806            panic!("expected successful completion response");
3807        };
3808        assert_eq!(response.choices.len(), 1);
3809
3810        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3811            panic!("expected assistant message");
3812        };
3813        assert_eq!(tool_calls.len(), 1);
3814        assert_eq!(tool_calls[0].id, "xxx");
3815        assert_eq!(tool_calls[0].function.name, "hello_world");
3816        assert_eq!(
3817            tool_calls[0].function.arguments,
3818            serde_json::json!({"city": "Paris"})
3819        );
3820    }
3821
3822    /// A `max_tokens`-capped turn still emits the tool call, with `arguments`
3823    /// cut off partway through the JSON object. Parsing strictly failed the
3824    /// *whole* response -- the text, usage, id and finish reason went with it
3825    /// -- where the streaming path keeps the turn and drops the unusable call.
3826    /// Reproduced live against DeepSeek (rig#2354) at 24/32/48/64-token
3827    /// budgets; this wire type backs every other OpenAI-compatible provider in
3828    /// the tree, so the same shape is pinned here.
3829    #[test]
3830    fn truncated_tool_arguments_do_not_destroy_the_response() {
3831        let request = r#"{
3832            "choices": [{
3833                "finish_reason": "length",
3834                "index": 0,
3835                "message": {
3836                    "role": "assistant",
3837                    "content": "Acknowledged.",
3838                    "tool_calls": [
3839                        { "type": "function", "id": "call_1", "function": { "name": "page", "arguments": "{\"team\":\"platform\"}" } },
3840                        { "type": "function", "id": "call_2", "function": { "name": "file_report", "arguments": "{\"summary\": " } }
3841                    ]
3842                }
3843            }],
3844            "created": 0,
3845            "model": "gpt-4o-mini",
3846            "object": "chat.completion",
3847            "usage": { "completion_tokens": 24, "prompt_tokens": 372, "total_tokens": 396 },
3848            "id": "chatcmpl-truncated"
3849        }
3850        "#;
3851
3852        let ApiResponse::Ok(response) =
3853            serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap()
3854        else {
3855            panic!("expected successful completion response");
3856        };
3857
3858        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3859            panic!("expected assistant message");
3860        };
3861        assert_eq!(
3862            tool_calls.len(),
3863            1,
3864            "the unusable call is dropped at decode; the complete one survives"
3865        );
3866
3867        let converted = response.normalize("openai").unwrap();
3868
3869        assert_eq!(
3870            converted.finish_reason(),
3871            Some(crate::completion::FinishReason::Length)
3872        );
3873        assert_eq!(converted.usage.total_tokens, 396);
3874        assert_eq!(converted.response_id.as_deref(), Some("chatcmpl-truncated"));
3875        let names = converted
3876            .choice
3877            .iter()
3878            .filter_map(|content| match content {
3879                completion::AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
3880                _ => None,
3881            })
3882            .collect::<Vec<_>>();
3883        assert_eq!(names, vec!["page"], "only the truncated call is dropped");
3884        assert!(
3885            converted.choice.iter().any(|content| matches!(
3886                content,
3887                completion::AssistantContent::Text(text) if text.text == "Acknowledged."
3888            )),
3889            "the turn's text survives: {:?}",
3890            converted.choice
3891        );
3892    }
3893
3894    fn response_with_tool_call(finish_reason: &str, call: serde_json::Value) -> serde_json::Value {
3895        serde_json::json!({
3896            "choices": [{
3897                "finish_reason": finish_reason,
3898                "index": 0,
3899                "message": {
3900                    "role": "assistant",
3901                    "content": "",
3902                    "tool_calls": [call]
3903                },
3904                "logprobs": null
3905            }],
3906            "created": 0,
3907            "model": "gpt-4o-mini",
3908            "object": "chat.completion",
3909            "system_fingerprint": null,
3910            "usage": { "completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2 },
3911            "id": "chatcmpl-tool-call"
3912        })
3913    }
3914
3915    /// Invalid JSON on a completed tool turn is a provider defect, not
3916    /// truncation evidence. It must stay visible on the native raw surface.
3917    #[test]
3918    fn malformed_completed_tool_call_is_not_silently_dropped() {
3919        let response = response_with_tool_call(
3920            "tool_calls",
3921            serde_json::json!({
3922                "type": "function",
3923                "id": "call_1",
3924                "function": { "name": "page", "arguments": "{\"team\":" }
3925            }),
3926        );
3927
3928        assert!(
3929            serde_json::from_value::<CompletionResponse>(response).is_err(),
3930            "ordinary malformed tool output must remain a loud response defect"
3931        );
3932    }
3933
3934    /// Repairing the arguments in a validation copy must not hide an
3935    /// independent defect on the same truncated call.
3936    #[test]
3937    fn truncated_tool_call_with_a_compound_defect_is_not_dropped() {
3938        let response = response_with_tool_call(
3939            "length",
3940            serde_json::json!({
3941                "type": "not_a_real_tool_type",
3942                "id": "call_1",
3943                "function": { "name": "page", "arguments": "{\"team\":" }
3944            }),
3945        );
3946
3947        assert!(
3948            serde_json::from_value::<CompletionResponse>(response).is_err(),
3949            "the unknown type must remain loud even beside truncated arguments"
3950        );
3951    }
3952
3953    /// Under `length`, an empty string means the turn ended before the first
3954    /// argument token. Treating it as `{}` could dispatch a zero-argument
3955    /// side-effect tool from an incomplete turn.
3956    #[test]
3957    fn output_length_drops_a_tool_call_with_no_argument_tokens() {
3958        let response = response_with_tool_call(
3959            "length",
3960            serde_json::json!({
3961                "type": "function",
3962                "id": "call_1",
3963                "function": { "name": "page", "arguments": "" }
3964            }),
3965        );
3966        let response: CompletionResponse =
3967            serde_json::from_value(response).expect("the truncated turn should survive");
3968        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
3969            panic!("expected assistant message");
3970        };
3971        assert!(tool_calls.is_empty());
3972    }
3973
3974    /// The choice-level truncation policy must not weaken a complete payload:
3975    /// an empty string and Groq's literal `"null"` are both parameterless
3976    /// invocations, and object-valued `arguments` (llama.cpp, Hugging Face)
3977    /// still pass through untouched.
3978    ///
3979    /// The `"null"` spelling is not hypothetical: every zero-argument call in
3980    /// `tests/cassettes/groq/agent_tool_sessions/parallel_tool_calls_single_turn_nonstreaming.yaml`
3981    /// carries it, so folding it to `{}` is what keeps the truncation sentinel
3982    /// from swallowing a real call.
3983    #[test]
3984    fn tolerant_tool_arguments_leave_complete_payloads_alone() {
3985        let request = r#"{
3986            "choices": [{
3987                "finish_reason": "tool_calls",
3988                "index": 0,
3989                "message": {
3990                    "role": "assistant",
3991                    "content": "",
3992                    "tool_calls": [
3993                        { "type": "function", "id": "a", "function": { "name": "ping", "arguments": "" } },
3994                        { "type": "function", "id": "b", "function": { "name": "hello", "arguments": { "city": "Paris" } } },
3995                        { "type": "function", "id": "c", "function": { "name": "pong", "arguments": "null" } },
3996                        { "type": "function", "id": "d", "function": { "name": "pang", "arguments": null } }
3997                    ]
3998                }
3999            }],
4000            "created": 0,
4001            "model": "gpt-4o-mini",
4002            "object": "chat.completion",
4003            "usage": { "completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2 },
4004            "id": "chatcmpl-complete"
4005        }
4006        "#;
4007
4008        let ApiResponse::Ok(response) =
4009            serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap()
4010        else {
4011            panic!("expected successful completion response");
4012        };
4013        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
4014            panic!("expected assistant message");
4015        };
4016        assert_eq!(tool_calls[0].function.arguments, serde_json::json!({}));
4017        assert_eq!(
4018            tool_calls[1].function.arguments,
4019            serde_json::json!({"city": "Paris"})
4020        );
4021        assert_eq!(
4022            tool_calls[2].function.arguments,
4023            serde_json::Value::Null,
4024            "Groq's `\"null\"` spelling parses, so the call survives untouched — \
4025             which is exactly why `null` cannot be a truncation sentinel"
4026        );
4027        assert_eq!(
4028            tool_calls[3].function.arguments,
4029            serde_json::Value::Null,
4030            "and the same for a bare JSON null in the non-string branch"
4031        );
4032
4033        let converted = response.normalize("openai").unwrap();
4034        assert_eq!(
4035            converted
4036                .choice
4037                .iter()
4038                .filter(|content| matches!(content, completion::AssistantContent::ToolCall(_)))
4039                .count(),
4040            4,
4041            "every completed parameterless call survives"
4042        );
4043    }
4044
4045    #[test]
4046    fn deserialize_llama_cpp_response_with_reasoning_content() {
4047        let request = r#"
4048        {
4049            "choices": [
4050                {
4051                    "finish_reason": "stop",
4052                    "index": 0,
4053                    "message": {
4054                        "role": "assistant",
4055                        "content": "",
4056                        "reasoning_content": "Now I understand the structure better. I need to: ..."
4057                    }
4058                }
4059            ],
4060            "created": 1776750378,
4061            "model": "unsloth/Qwen3.6-35B-A3B-GGUF:Q8_0",
4062            "system_fingerprint": "fp_xxx",
4063            "object": "chat.completion",
4064            "usage": {
4065                "completion_tokens": 920,
4066                "prompt_tokens": 27806,
4067                "total_tokens": 28726,
4068                "prompt_tokens_details": { "cached_tokens": 18698 }
4069            },
4070            "id": "chatcmpl-xxxx",
4071            "timings": {
4072                "cache_n": 18698,
4073                "prompt_n": 9108,
4074                "prompt_ms": 226645.81,
4075                "prompt_per_token_ms": 24.884256697408873,
4076                "prompt_per_second": 40.186050648807495,
4077                "predicted_n": 920,
4078                "predicted_ms": 177167.955,
4079                "predicted_per_token_ms": 192.57386413043477,
4080                "predicted_per_second": 5.192812661860888
4081            }
4082        }
4083        "#;
4084        let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
4085        let ApiResponse::Ok(response) = response else {
4086            panic!("expected successful completion response");
4087        };
4088
4089        let response: completion::CompletionResponse =
4090            response
4091                .normalize(<crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::PROVIDER_NAME)
4092                .unwrap();
4093
4094        assert_eq!(response.choice.len(), 1);
4095
4096        let Some(completion::message::AssistantContent::Reasoning(reasoning)) =
4097            response.choice.first()
4098        else {
4099            panic!("expected assistant content to be reasoning");
4100        };
4101        assert_eq!(
4102            reasoning.first_text(),
4103            Some("Now I understand the structure better. I need to: ...")
4104        );
4105    }
4106
4107    #[test]
4108    fn pdf_base64_document_serializes_as_file_content_part() {
4109        let doc = message::UserContent::Document(message::Document {
4110            data: DocumentSourceKind::Base64("JVBERi0xLjQK".into()),
4111            media_type: Some(message::DocumentMediaType::PDF),
4112            additional_params: None,
4113        });
4114        let converted: UserContent = doc.try_into().expect("conversion should succeed");
4115        let json = serde_json::to_value(&converted).expect("serialize");
4116
4117        assert_eq!(json["type"], "file");
4118        assert_eq!(
4119            json["file"]["file_data"],
4120            "data:application/pdf;base64,JVBERi0xLjQK"
4121        );
4122        assert_eq!(json["file"]["filename"], "document.pdf");
4123        assert!(json["file"].get("file_id").is_none());
4124    }
4125
4126    #[test]
4127    fn file_id_document_serializes_as_file_content_part() {
4128        let doc = message::UserContent::Document(message::Document {
4129            data: DocumentSourceKind::FileId("file_abc".into()),
4130            media_type: None,
4131            additional_params: None,
4132        });
4133        let converted: UserContent = doc.try_into().expect("conversion should succeed");
4134        let json = serde_json::to_value(&converted).expect("serialize");
4135
4136        assert_eq!(json["type"], "file");
4137        assert_eq!(json["file"]["file_id"], "file_abc");
4138        assert!(json["file"].get("file_data").is_none());
4139    }
4140
4141    #[test]
4142    fn base64_image_without_detail_defaults_to_auto() {
4143        let image = message::UserContent::Image(message::Image {
4144            data: DocumentSourceKind::Base64("iVBORw0KGgo=".into()),
4145            media_type: Some(message::ImageMediaType::PNG),
4146            detail: None,
4147            additional_params: None,
4148        });
4149        let converted: UserContent = image.try_into().expect("conversion should succeed");
4150        let UserContent::Image { image_url } = converted else {
4151            panic!("expected image content");
4152        };
4153
4154        assert_eq!(image_url.url, "data:image/png;base64,iVBORw0KGgo=");
4155        assert_eq!(image_url.detail, Some(ImageDetail::Auto));
4156    }
4157
4158    // Regression guard: callers passing markdown/plain text wrapped in
4159    // `UserContent::Document` should keep getting flattened to `text`.
4160    #[test]
4161    fn non_pdf_document_still_serializes_as_text() {
4162        let doc = message::UserContent::Document(message::Document {
4163            data: DocumentSourceKind::String("# Markdown".into()),
4164            media_type: None,
4165            additional_params: None,
4166        });
4167        let converted: UserContent = doc.try_into().expect("conversion should succeed");
4168        let json = serde_json::to_value(&converted).expect("serialize");
4169
4170        assert_eq!(json["type"], "text");
4171        assert_eq!(json["text"], "# Markdown");
4172    }
4173
4174    #[test]
4175    fn pdf_url_document_returns_conversion_error() {
4176        let doc = message::UserContent::Document(message::Document {
4177            data: DocumentSourceKind::Url("https://example.com/x.pdf".into()),
4178            media_type: Some(message::DocumentMediaType::PDF),
4179            additional_params: None,
4180        });
4181        let res: Result<UserContent, _> = doc.try_into();
4182        assert!(matches!(
4183            res,
4184            Err(message::MessageError::ConversionError(_))
4185        ));
4186    }
4187
4188    #[test]
4189    fn pdf_raw_document_returns_conversion_error() {
4190        let doc = message::UserContent::Document(message::Document {
4191            data: DocumentSourceKind::Raw(b"%PDF-1.4\n".to_vec()),
4192            media_type: Some(message::DocumentMediaType::PDF),
4193            additional_params: None,
4194        });
4195        let res: Result<UserContent, _> = doc.try_into();
4196        assert!(matches!(
4197            res,
4198            Err(message::MessageError::ConversionError(_))
4199        ));
4200    }
4201
4202    #[test]
4203    fn file_user_content_deserializes_from_wire_json() {
4204        let raw = r#"{"type":"file","file":{"file_data":"data:application/pdf;base64,AAAA","filename":"x.pdf"}}"#;
4205        let parsed: UserContent = serde_json::from_str(raw).expect("deserialize");
4206        let UserContent::File { file } = parsed else {
4207            panic!("expected File variant");
4208        };
4209        assert_eq!(
4210            file.file_data.as_deref(),
4211            Some("data:application/pdf;base64,AAAA")
4212        );
4213        assert_eq!(file.filename.as_deref(), Some("x.pdf"));
4214        assert!(file.file_id.is_none());
4215    }
4216
4217    #[test]
4218    fn file_variant_round_trips_back_to_pdf_document() {
4219        let wire = UserContent::File {
4220            file: FileData {
4221                file_data: Some("data:application/pdf;base64,QUJD".to_string()),
4222                file_id: None,
4223                filename: Some("document.pdf".to_string()),
4224            },
4225        };
4226        let rig: message::UserContent = wire.into();
4227        let message::UserContent::Document(doc) = rig else {
4228            panic!("expected Document");
4229        };
4230        assert_eq!(doc.media_type, Some(message::DocumentMediaType::PDF));
4231        assert!(matches!(doc.data, DocumentSourceKind::Base64(ref b) if b == "QUJD"));
4232    }
4233
4234    #[test]
4235    fn file_variant_with_file_id_only_round_trips_to_document_file_id() {
4236        let wire = UserContent::File {
4237            file: FileData {
4238                file_data: None,
4239                file_id: Some("file_abc".to_string()),
4240                filename: None,
4241            },
4242        };
4243        let rig: message::UserContent = wire.into();
4244        let message::UserContent::Document(doc) = rig else {
4245            panic!("expected Document");
4246        };
4247        assert_eq!(doc.media_type, None);
4248        assert!(matches!(doc.data, DocumentSourceKind::FileId(ref id) if id == "file_abc"));
4249
4250        let converted: UserContent = message::UserContent::Document(doc)
4251            .try_into()
4252            .expect("conversion should succeed");
4253        let json = serde_json::to_value(&converted).expect("serialize");
4254
4255        assert_eq!(json["type"], "file");
4256        assert_eq!(json["file"]["file_id"], "file_abc");
4257        assert!(json["file"].get("file_data").is_none());
4258    }
4259
4260    // A mixed text + PDF message must produce one User message carrying both
4261    // parts, rather than being flattened or split at the User content site.
4262    #[test]
4263    fn mixed_text_and_pdf_user_message_produces_two_content_parts() {
4264        let user = message::Message::User {
4265            content: vec![
4266                message::UserContent::text("What is in this PDF?"),
4267                message::UserContent::Document(message::Document {
4268                    data: DocumentSourceKind::Base64("JVBERi0K".into()),
4269                    media_type: Some(message::DocumentMediaType::PDF),
4270                    additional_params: None,
4271                }),
4272            ],
4273        };
4274        let converted: Vec<Message> = user.try_into().expect("conversion should succeed");
4275        assert_eq!(converted.len(), 1);
4276        let Message::User { content, .. } = &converted[0] else {
4277            panic!("expected user message");
4278        };
4279        let parts: Vec<&UserContent> = content.iter().collect();
4280        assert_eq!(parts.len(), 2);
4281        assert!(matches!(parts[0], UserContent::Text { .. }));
4282        assert!(matches!(parts[1], UserContent::File { .. }));
4283    }
4284
4285    #[tokio::test]
4286    async fn completion_preserves_raw_provider_error_json_on_api_error_envelope() {
4287        use crate::client::CompletionClient;
4288        use crate::completion::CompletionModel;
4289        use crate::providers::openai::CompletionsClient;
4290        use crate::test_utils::RecordingHttpClient;
4291
4292        let body = r#"{"message":"slow down","type":"rate_limit","code":"rate_limit_exceeded"}"#;
4293        let http_client =
4294            RecordingHttpClient::with_error_response(http::StatusCode::ACCEPTED, body);
4295        let client = CompletionsClient::builder()
4296            .api_key("test-key")
4297            .http_client(http_client)
4298            .build()
4299            .expect("build client");
4300        let model = client.completion_model("gpt-4o-mini");
4301        let request = model.completion_request("hello").build();
4302
4303        let error = model
4304            .completion(request)
4305            .await
4306            .expect_err("completion should fail with provider error envelope");
4307
4308        match &error {
4309            CompletionError::ProviderResponse(stored) => {
4310                assert_eq!(stored.body, body);
4311                assert_eq!(stored.status, Some(http::StatusCode::ACCEPTED));
4312                assert_eq!(error.provider_response_body(), Some(body));
4313                assert_eq!(
4314                    error.provider_response_status(),
4315                    Some(http::StatusCode::ACCEPTED)
4316                );
4317                let json = error
4318                    .provider_response_json()
4319                    .expect("raw body should be valid JSON")
4320                    .expect("parsed JSON should be present");
4321                assert_eq!(json["code"], "rate_limit_exceeded");
4322                assert_eq!(json["type"], "rate_limit");
4323            }
4324            other => panic!("expected ProviderResponse, got {other:?}"),
4325        }
4326    }
4327
4328    #[tokio::test]
4329    async fn completion_http_non_success_preserves_status_and_body() {
4330        use crate::client::CompletionClient;
4331        use crate::completion::CompletionModel;
4332        use crate::providers::openai::CompletionsClient;
4333        use crate::test_utils::RecordingHttpClient;
4334
4335        let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error"}}"#;
4336        let http_client =
4337            RecordingHttpClient::with_error_response(http::StatusCode::TOO_MANY_REQUESTS, body);
4338        let client = CompletionsClient::builder()
4339            .api_key("test-key")
4340            .http_client(http_client)
4341            .build()
4342            .expect("build client");
4343        let model = client.completion_model("gpt-4o-mini");
4344        let request = model.completion_request("hello").build();
4345
4346        let error = model
4347            .completion(request)
4348            .await
4349            .expect_err("completion should fail with non-success status");
4350
4351        // rig#2314: a provider with a request-id contract preserves its
4352        // non-success responses as ProviderResponse, so the transport id has
4353        // a home on the error; this mock sent no header, so the id is None.
4354        assert!(matches!(error, CompletionError::ProviderResponse(_)));
4355        assert_eq!(error.provider_request_id(), None);
4356        assert_eq!(
4357            error.provider_response_status(),
4358            Some(http::StatusCode::TOO_MANY_REQUESTS)
4359        );
4360        assert_eq!(error.provider_response_body(), Some(body));
4361        let json = error
4362            .provider_response_json()
4363            .expect("raw body should be valid JSON")
4364            .expect("parsed JSON should be present");
4365        assert_eq!(json["error"]["type"], "rate_limit_error");
4366    }
4367
4368    /// Raw-capture tests: the `normalize` shape through the OpenAI-compatible
4369    /// model, driven end to end over a mock transport that hands back a real
4370    /// chat-completions body *and* an `x-request-id` response header, so the
4371    /// same fixture serves the capture contract and the Part A parity
4372    /// contract. `with_error_response_headers` is the only unary double that
4373    /// carries headers; with `200 OK` it is simply a successful response with
4374    /// headers (`completion_send` already relies on that).
4375    mod raw_capture {
4376        use super::*;
4377        use crate::client::CompletionClient;
4378        use crate::completion::CompletionModel as _;
4379        use crate::providers::openai::CompletionsClient;
4380        use crate::test_utils::RecordingHttpClient;
4381
4382        const REQUEST_ID: &str = "req_unit_chat_0001";
4383
4384        /// A chat-completions body carrying fields the normalized response
4385        /// provably lacks (`system_fingerprint`, `service_tier`), so the
4386        /// captured value can be shown to answer more than `completion()`.
4387        const BODY: &str = r#"{
4388            "id": "chatcmpl-raw-1",
4389            "object": "chat.completion",
4390            "created": 1700000000,
4391            "model": "gpt-4o-mini-2024-07-18",
4392            "system_fingerprint": "fp_unit_test",
4393            "service_tier": "default",
4394            "choices": [{
4395                "index": 0,
4396                "message": {"role": "assistant", "content": "hello"},
4397                "logprobs": null,
4398                "finish_reason": "stop"
4399            }],
4400            "usage": {"prompt_tokens": 4, "completion_tokens": 1, "total_tokens": 5}
4401        }"#;
4402
4403        fn model() -> CompletionModel<RecordingHttpClient> {
4404            let mut headers = http::HeaderMap::new();
4405            headers.insert("x-request-id", http::HeaderValue::from_static(REQUEST_ID));
4406            let http_client = RecordingHttpClient::with_error_response_headers(
4407                http::StatusCode::OK,
4408                BODY,
4409                headers,
4410            );
4411            let client = CompletionsClient::builder()
4412                .api_key("test-key")
4413                .http_client(http_client)
4414                .build()
4415                .expect("build client");
4416            client.completion_model("gpt-4o-mini")
4417        }
4418
4419        /// The load-bearing capture property: `raw` is the wire type as rig
4420        /// parsed it — it deserializes back into
4421        /// `openai::completion::CompletionResponse` and re-serializes to the
4422        /// identical value — and re-normalizing that capture (with the header
4423        /// id reattached, exactly as `completion()` does) reproduces every
4424        /// normalized field. Also reads a field rig does not normalize
4425        /// (`system_fingerprint`) off the capture.
4426        #[tokio::test]
4427        async fn completion_captures_raw_that_round_trips_into_the_wire_type() {
4428            let model = model();
4429
4430            let response = model
4431                .completion(model.completion_request("hello").build())
4432                .await
4433                .expect("completion");
4434
4435            let raw = &response.raw;
4436            let typed = super::CompletionResponse::deserialize(raw)
4437                .expect("raw must deserialize into the provider wire type");
4438            assert_eq!(
4439                serde_json::to_value(&typed).expect("re-serialize"),
4440                *raw,
4441                "the capture must be exactly what the wire type serializes to"
4442            );
4443            assert_eq!(typed.system_fingerprint.as_deref(), Some("fp_unit_test"));
4444            assert_eq!(raw["service_tier"], "default");
4445
4446            // The capture and the normalized response tell one story.
4447            let renormalized = typed
4448                .normalize(<crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::PROVIDER_NAME)
4449                .expect("re-normalize the capture")
4450                .with_optional_provider_request_id(Some(REQUEST_ID.to_string()));
4451            assert_eq!(response.identity(), renormalized.identity());
4452            assert_eq!(response.finish_reason(), renormalized.finish_reason());
4453            assert_eq!(response.model, renormalized.model);
4454            assert_eq!(response.usage, renormalized.usage);
4455            assert_eq!(response.choice, renormalized.choice);
4456            assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
4457            assert_eq!(
4458                response.finish_reason(),
4459                Some(crate::completion::FinishReason::Stop)
4460            );
4461        }
4462
4463        /// Part A parity, unit form: the typed route
4464        /// `raw_completion_with_request_id` → `normalize` →
4465        /// `with_optional_provider_request_id` reproduces `completion()` on
4466        /// identity, finish reason, model and usage — and specifically the
4467        /// transport id, which lives only on the response header and which
4468        /// plain `raw_completion` drops. This is why the pair is public.
4469        #[tokio::test]
4470        async fn raw_completion_with_request_id_reproduces_completion() {
4471            let model = model();
4472
4473            let (raw, id) = model
4474                .raw_completion_with_request_id(model.completion_request("hello").build())
4475                .await
4476                .expect("typed route");
4477            assert_eq!(id.as_deref(), Some(REQUEST_ID));
4478            let reassembled = raw
4479                .normalize(<crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::PROVIDER_NAME)
4480                .expect("normalize")
4481                .with_optional_provider_request_id(id);
4482
4483            let normalized = model
4484                .completion(model.completion_request("hello").build())
4485                .await
4486                .expect("normalized route");
4487
4488            assert_eq!(reassembled.identity(), normalized.identity());
4489            assert_eq!(reassembled.finish_reason(), normalized.finish_reason());
4490            assert_eq!(reassembled.model, normalized.model);
4491            assert_eq!(reassembled.usage, normalized.usage);
4492            assert_eq!(reassembled.provider_request_id.as_deref(), Some(REQUEST_ID));
4493            assert_eq!(normalized.provider_request_id.as_deref(), Some(REQUEST_ID));
4494        }
4495    }
4496}