Skip to main content

rig_core/providers/openai/completion/
mod.rs

1// ================================================================
2// OpenAI Completion API
3// ================================================================
4
5use super::{client::ApiResponse, streaming::StreamingCompletionResponse};
6use crate::completion::{
7    CompletionError, CompletionRequest as CoreCompletionRequest, GetTokenUsage,
8};
9use crate::http_client::{self, HttpClientExt};
10use crate::message::{AudioMediaType, DocumentSourceKind, ImageDetail, MimeType};
11use crate::one_or_many::string_or_one_or_many;
12use crate::telemetry::{
13    CompletionOperation, CompletionSpanBuilder, ProviderResponseExt, SpanCombinator,
14};
15use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
16use crate::{OneOrMany, completion, json_utils, message};
17use serde::{Deserialize, Serialize, Serializer};
18use std::convert::Infallible;
19use std::fmt;
20use tracing::{Instrument, Level, enabled};
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>(
29    content: &OneOrMany<UserContent>,
30    serializer: S,
31) -> Result<S::Ok, S::Error>
32where
33    S: Serializer,
34{
35    if content.len() == 1
36        && let UserContent::Text { text, .. } = content.first_ref()
37    {
38        return serializer.serialize_str(text);
39    }
40    content.serialize(serializer)
41}
42
43/// `gpt-5.6` completion model (alias that routes to GPT-5.6 Sol)
44pub const GPT_5_6: &str = "gpt-5.6";
45
46/// `gpt-5.6-sol` completion model
47pub const GPT_5_6_SOL: &str = "gpt-5.6-sol";
48
49/// `gpt-5.6-terra` completion model
50pub const GPT_5_6_TERRA: &str = "gpt-5.6-terra";
51
52/// `gpt-5.6-luna` completion model
53pub const GPT_5_6_LUNA: &str = "gpt-5.6-luna";
54
55/// `gpt-5.5` completion model
56pub const GPT_5_5: &str = "gpt-5.5";
57
58/// `gpt-5.2` completion model
59pub const GPT_5_2: &str = "gpt-5.2";
60
61/// `gpt-5.1` completion model
62pub const GPT_5_1: &str = "gpt-5.1";
63
64/// `gpt-5` completion model
65pub const GPT_5: &str = "gpt-5";
66/// `gpt-5` completion model
67pub const GPT_5_MINI: &str = "gpt-5-mini";
68/// `gpt-5` completion model
69pub const GPT_5_NANO: &str = "gpt-5-nano";
70
71/// `gpt-4.5-preview` completion model
72pub const GPT_4_5_PREVIEW: &str = "gpt-4.5-preview";
73/// `gpt-4.5-preview-2025-02-27` completion model
74pub const GPT_4_5_PREVIEW_2025_02_27: &str = "gpt-4.5-preview-2025-02-27";
75/// `gpt-4o-2024-11-20` completion model (this is newer than 4o)
76pub const GPT_4O_2024_11_20: &str = "gpt-4o-2024-11-20";
77/// `gpt-4o` completion model
78pub const GPT_4O: &str = "gpt-4o";
79/// `gpt-4o-mini` completion model
80pub const GPT_4O_MINI: &str = "gpt-4o-mini";
81/// `gpt-4o-2024-05-13` completion model
82pub const GPT_4O_2024_05_13: &str = "gpt-4o-2024-05-13";
83/// `gpt-4-turbo` completion model
84pub const GPT_4_TURBO: &str = "gpt-4-turbo";
85/// `gpt-4-turbo-2024-04-09` completion model
86pub const GPT_4_TURBO_2024_04_09: &str = "gpt-4-turbo-2024-04-09";
87/// `gpt-4-turbo-preview` completion model
88pub const GPT_4_TURBO_PREVIEW: &str = "gpt-4-turbo-preview";
89/// `gpt-4-0125-preview` completion model
90pub const GPT_4_0125_PREVIEW: &str = "gpt-4-0125-preview";
91/// `gpt-4-1106-preview` completion model
92pub const GPT_4_1106_PREVIEW: &str = "gpt-4-1106-preview";
93/// `gpt-4-vision-preview` completion model
94pub const GPT_4_VISION_PREVIEW: &str = "gpt-4-vision-preview";
95/// `gpt-4-1106-vision-preview` completion model
96pub const GPT_4_1106_VISION_PREVIEW: &str = "gpt-4-1106-vision-preview";
97/// `gpt-4` completion model
98pub const GPT_4: &str = "gpt-4";
99/// `gpt-4-0613` completion model
100pub const GPT_4_0613: &str = "gpt-4-0613";
101/// `gpt-4-32k` completion model
102pub const GPT_4_32K: &str = "gpt-4-32k";
103/// `gpt-4-32k-0613` completion model
104pub const GPT_4_32K_0613: &str = "gpt-4-32k-0613";
105
106/// `o4-mini-2025-04-16` completion model
107pub const O4_MINI_2025_04_16: &str = "o4-mini-2025-04-16";
108/// `o4-mini` completion model
109pub const O4_MINI: &str = "o4-mini";
110/// `o3` completion model
111pub const O3: &str = "o3";
112/// `o3-mini` completion model
113pub const O3_MINI: &str = "o3-mini";
114/// `o3-mini-2025-01-31` completion model
115pub const O3_MINI_2025_01_31: &str = "o3-mini-2025-01-31";
116/// `o1-pro` completion model
117pub const O1_PRO: &str = "o1-pro";
118/// `o1`` completion model
119pub const O1: &str = "o1";
120/// `o1-2024-12-17` completion model
121pub const O1_2024_12_17: &str = "o1-2024-12-17";
122/// `o1-preview` completion model
123pub const O1_PREVIEW: &str = "o1-preview";
124/// `o1-preview-2024-09-12` completion model
125pub const O1_PREVIEW_2024_09_12: &str = "o1-preview-2024-09-12";
126/// `o1-mini completion model
127pub const O1_MINI: &str = "o1-mini";
128/// `o1-mini-2024-09-12` completion model
129pub const O1_MINI_2024_09_12: &str = "o1-mini-2024-09-12";
130
131/// `gpt-4.1-mini` completion model
132pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
133/// `gpt-4.1-nano` completion model
134pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
135/// `gpt-4.1-2025-04-14` completion model
136pub const GPT_4_1_2025_04_14: &str = "gpt-4.1-2025-04-14";
137/// `gpt-4.1` completion model
138pub const GPT_4_1: &str = "gpt-4.1";
139
140#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
141#[serde(tag = "role", rename_all = "lowercase")]
142pub enum Message {
143    #[serde(alias = "developer")]
144    System {
145        #[serde(deserialize_with = "string_or_one_or_many")]
146        content: OneOrMany<SystemContent>,
147        #[serde(skip_serializing_if = "Option::is_none")]
148        name: Option<String>,
149    },
150    User {
151        #[serde(
152            deserialize_with = "string_or_one_or_many",
153            serialize_with = "serialize_user_content"
154        )]
155        content: OneOrMany<UserContent>,
156        #[serde(skip_serializing_if = "Option::is_none")]
157        name: Option<String>,
158    },
159    // Gemini-backed OpenAI-compatible gateways (e.g. OpenRouter) can answer
160    // with `role: "model"`; accept it on deserialization.
161    #[serde(alias = "model")]
162    Assistant {
163        #[serde(
164            default,
165            deserialize_with = "json_utils::string_or_vec",
166            skip_serializing_if = "Vec::is_empty",
167            serialize_with = "serialize_assistant_content_vec"
168        )]
169        content: Vec<AssistantContent>,
170        // OpenAI-compatible providers expose hidden reasoning on this non-standard
171        // field, and some require it to be echoed back on assistant tool-call turns.
172        // Serialized as `reasoning_content` (llama.cpp/DeepSeek dialect); the
173        // `reasoning` alias accepts OpenRouter responses.
174        #[serde(
175            skip_serializing_if = "Option::is_none",
176            rename = "reasoning_content",
177            alias = "reasoning"
178        )]
179        reasoning: Option<String>,
180        #[serde(skip_serializing_if = "Option::is_none")]
181        refusal: Option<String>,
182        #[serde(skip_serializing_if = "Option::is_none")]
183        audio: Option<AudioAssistant>,
184        #[serde(skip_serializing_if = "Option::is_none")]
185        name: Option<String>,
186        #[serde(
187            default,
188            deserialize_with = "json_utils::null_or_vec",
189            skip_serializing_if = "Vec::is_empty"
190        )]
191        tool_calls: Vec<ToolCall>,
192        /// Structured reasoning blocks used by OpenAI-compatible providers
193        /// such as OpenRouter. Empty (and omitted from the wire) for
194        /// providers that do not emit or accept them.
195        #[serde(default, skip_serializing_if = "Vec::is_empty")]
196        reasoning_details: Vec<ReasoningDetails>,
197        /// Generated images returned by image-generation models (OpenRouter's
198        /// sibling `images` array). Inbound only — never serialized back into
199        /// a request.
200        #[serde(default, skip_serializing)]
201        images: Vec<ResponseImage>,
202    },
203    #[serde(rename = "tool")]
204    ToolResult {
205        tool_call_id: String,
206        content: ToolResultContentValue,
207    },
208}
209
210impl Message {
211    pub fn system(content: &str) -> Self {
212        Message::System {
213            content: OneOrMany::one(content.to_owned().into()),
214            name: None,
215        }
216    }
217}
218
219fn history_contains_tool_result(messages: &[Message]) -> bool {
220    messages
221        .iter()
222        .any(|message| matches!(message, Message::ToolResult { .. }))
223}
224
225#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
226pub struct AudioAssistant {
227    pub id: String,
228}
229
230/// Structured reasoning blocks attached to assistant messages by
231/// OpenAI-compatible providers such as OpenRouter (`reasoning_details`).
232///
233/// The `Option` fields are intentionally serialized even when `None`
234/// (`"format":null,"id":null`) to match the provider wire format.
235#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
236#[serde(tag = "type", rename_all = "snake_case")]
237pub enum ReasoningDetails {
238    #[serde(rename = "reasoning.summary")]
239    Summary {
240        id: Option<String>,
241        format: Option<String>,
242        index: Option<usize>,
243        summary: String,
244    },
245    #[serde(rename = "reasoning.encrypted")]
246    Encrypted {
247        id: Option<String>,
248        format: Option<String>,
249        index: Option<usize>,
250        data: String,
251    },
252    #[serde(rename = "reasoning.text")]
253    Text {
254        id: Option<String>,
255        format: Option<String>,
256        index: Option<usize>,
257        text: Option<String>,
258        signature: Option<String>,
259    },
260}
261
262/// An image emitted by an image-generation model. OpenRouter returns generated
263/// images out-of-band from `content`, as a sibling `images` array on the
264/// assistant message. Each entry mirrors the request-side `image_url` content
265/// part structure.
266#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
267pub struct ResponseImage {
268    pub image_url: ImageUrl,
269}
270
271#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
272pub struct SystemContent {
273    #[serde(default)]
274    pub r#type: SystemContentType,
275    pub text: String,
276}
277
278#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
279#[serde(rename_all = "lowercase")]
280pub enum SystemContentType {
281    #[default]
282    Text,
283}
284
285#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
286#[serde(tag = "type", rename_all = "lowercase")]
287pub enum AssistantContent {
288    Text { text: String },
289    Refusal { refusal: String },
290}
291
292impl From<AssistantContent> for completion::AssistantContent {
293    fn from(value: AssistantContent) -> Self {
294        match value {
295            AssistantContent::Text { text, .. } => completion::AssistantContent::text(text),
296            AssistantContent::Refusal { refusal } => completion::AssistantContent::text(refusal),
297        }
298    }
299}
300
301#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
302#[serde(tag = "type", rename_all = "lowercase")]
303pub enum UserContent {
304    Text {
305        text: String,
306    },
307    #[serde(rename = "image_url")]
308    Image {
309        image_url: ImageUrl,
310    },
311    /// Audio content part. Serialized with OpenAI's `input_audio` wire tag;
312    /// the legacy `audio` tag is still accepted on deserialization.
313    #[serde(rename = "input_audio", alias = "audio")]
314    Audio {
315        input_audio: InputAudio,
316    },
317    /// File content part for documents such as PDFs.
318    ///
319    /// Maps to OpenAI's `{"type":"file","file":{...}}` content type. Either
320    /// `file_data` (a base64 data URI like `data:application/pdf;base64,...`)
321    /// or `file_id` (a previously uploaded file reference) must be set.
322    File {
323        file: FileData,
324    },
325    /// Video content part (URL or base64 data URI), used by OpenAI-compatible
326    /// providers such as OpenRouter. Wire tag: `video_url`.
327    #[serde(rename = "video_url")]
328    Video {
329        video_url: VideoUrl,
330    },
331}
332
333#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
334pub struct ImageUrl {
335    pub url: String,
336    /// Image detail level. Optional so that providers whose wire format omits
337    /// it (e.g. OpenRouter) can leave the key out entirely.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub detail: Option<ImageDetail>,
340}
341
342/// Video payload for [`UserContent::Video`].
343///
344/// `url` is either a publicly accessible URL or a base64 data URI
345/// (e.g. `data:video/mp4;base64,...`).
346#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
347pub struct VideoUrl {
348    pub url: String,
349}
350
351#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
352pub struct InputAudio {
353    pub data: String,
354    pub format: AudioMediaType,
355}
356
357/// File payload for [`UserContent::File`].
358///
359/// At least one of `file_data` or `file_id` must be set for the content part
360/// to be accepted by OpenAI's chat completions API. `filename` is optional
361/// but recommended.
362#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
363pub struct FileData {
364    /// Inline file data as a base64 data URI, e.g.
365    /// `data:application/pdf;base64,JVBERi0xLjQK...`.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub file_data: Option<String>,
368    /// Identifier of a previously uploaded file (OpenAI Files API).
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub file_id: Option<String>,
371    /// Display name of the file. Recommended for inline `file_data`.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub filename: Option<String>,
374}
375
376#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
377pub struct ToolResultContent {
378    #[serde(default)]
379    r#type: ToolResultContentType,
380    pub text: String,
381}
382
383#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
384#[serde(rename_all = "lowercase")]
385pub enum ToolResultContentType {
386    #[default]
387    Text,
388}
389
390impl FromStr for ToolResultContent {
391    type Err = Infallible;
392
393    fn from_str(s: &str) -> Result<Self, Self::Err> {
394        Ok(s.to_owned().into())
395    }
396}
397
398impl From<String> for ToolResultContent {
399    fn from(s: String) -> Self {
400        ToolResultContent {
401            r#type: ToolResultContentType::default(),
402            text: s,
403        }
404    }
405}
406
407#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
408#[serde(untagged)]
409pub enum ToolResultContentValue {
410    Array(Vec<ToolResultContent>),
411    String(String),
412}
413
414impl ToolResultContentValue {
415    pub fn from_string(s: String, use_array_format: bool) -> Self {
416        if use_array_format {
417            ToolResultContentValue::Array(vec![ToolResultContent::from(s)])
418        } else {
419            ToolResultContentValue::String(s)
420        }
421    }
422
423    pub fn as_text(&self) -> String {
424        match self {
425            ToolResultContentValue::Array(arr) => arr
426                .iter()
427                .map(|c| c.text.clone())
428                .collect::<Vec<_>>()
429                .join("\n"),
430            ToolResultContentValue::String(s) => s.clone(),
431        }
432    }
433
434    pub fn to_array(&self) -> Self {
435        match self {
436            ToolResultContentValue::Array(_) => self.clone(),
437            ToolResultContentValue::String(s) => {
438                ToolResultContentValue::Array(vec![ToolResultContent::from(s.clone())])
439            }
440        }
441    }
442}
443
444#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
445pub struct ToolCall {
446    pub id: String,
447    #[serde(default)]
448    pub r#type: ToolType,
449    pub function: Function,
450}
451
452#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
453#[serde(rename_all = "lowercase")]
454pub enum ToolType {
455    #[default]
456    Function,
457}
458
459/// Function definition for a tool, with optional strict mode
460#[derive(Debug, Deserialize, Serialize, Clone)]
461pub struct FunctionDefinition {
462    pub name: String,
463    pub description: String,
464    pub parameters: serde_json::Value,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub strict: Option<bool>,
467}
468
469#[derive(Debug, Deserialize, Serialize, Clone)]
470pub struct ToolDefinition {
471    pub r#type: String,
472    pub function: FunctionDefinition,
473}
474
475impl From<completion::ToolDefinition> for ToolDefinition {
476    fn from(tool: completion::ToolDefinition) -> Self {
477        Self {
478            r#type: "function".into(),
479            function: FunctionDefinition {
480                name: tool.name,
481                description: tool.description,
482                parameters: tool.parameters,
483                strict: None,
484            },
485        }
486    }
487}
488
489impl ToolDefinition {
490    /// Apply strict mode to this tool definition.
491    /// This sets `strict: true` and sanitizes the schema to meet OpenAI requirements.
492    pub fn with_strict(mut self) -> Self {
493        self.function.strict = Some(true);
494        super::sanitize_schema(&mut self.function.parameters);
495        self
496    }
497}
498
499#[derive(Default, Clone, Debug, PartialEq)]
500#[non_exhaustive]
501pub enum ToolChoice {
502    #[default]
503    Auto,
504    None,
505    Required,
506    /// Force the model to call one specific function:
507    /// `{"type": "function", "function": {"name": "..."}}`.
508    Function {
509        name: String,
510    },
511}
512
513#[derive(Deserialize, Serialize)]
514struct ToolChoiceFunctionName {
515    name: String,
516}
517
518#[derive(Deserialize, Serialize)]
519#[serde(tag = "type", rename_all = "snake_case")]
520enum ToolChoiceFunctionRepr {
521    Function { function: ToolChoiceFunctionName },
522}
523
524impl Serialize for ToolChoice {
525    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
526        match self {
527            Self::Auto => serializer.serialize_str("auto"),
528            Self::None => serializer.serialize_str("none"),
529            Self::Required => serializer.serialize_str("required"),
530            Self::Function { name } => ToolChoiceFunctionRepr::Function {
531                function: ToolChoiceFunctionName { name: name.clone() },
532            }
533            .serialize(serializer),
534        }
535    }
536}
537
538impl<'de> Deserialize<'de> for ToolChoice {
539    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
540        #[derive(Deserialize)]
541        #[serde(untagged)]
542        enum Repr {
543            Mode(String),
544            Function(ToolChoiceFunctionRepr),
545        }
546
547        match Repr::deserialize(deserializer)? {
548            Repr::Mode(mode) => match mode.as_str() {
549                "auto" => Ok(Self::Auto),
550                "none" => Ok(Self::None),
551                "required" => Ok(Self::Required),
552                other => Err(serde::de::Error::custom(format!(
553                    "unknown tool_choice mode {other:?}"
554                ))),
555            },
556            Repr::Function(ToolChoiceFunctionRepr::Function {
557                function: ToolChoiceFunctionName { name },
558            }) => Ok(Self::Function { name }),
559        }
560    }
561}
562
563impl ToolChoice {
564    /// Force a call to the named function.
565    pub fn function(name: impl Into<String>) -> Self {
566        Self::Function { name: name.into() }
567    }
568}
569
570impl TryFrom<crate::message::ToolChoice> for ToolChoice {
571    type Error = CompletionError;
572    fn try_from(value: crate::message::ToolChoice) -> Result<Self, Self::Error> {
573        let res = match value {
574            message::ToolChoice::Specific { function_names } => {
575                let [name] = function_names.as_slice() else {
576                    return Err(CompletionError::ProviderError(
577                        "Provider only supports forcing exactly one specific tool".to_string(),
578                    ));
579                };
580                Self::function(name)
581            }
582            message::ToolChoice::Auto => Self::Auto,
583            message::ToolChoice::None => Self::None,
584            message::ToolChoice::Required => Self::Required,
585        };
586
587        Ok(res)
588    }
589}
590
591#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
592pub struct Function {
593    pub name: String,
594    #[serde(
595        serialize_with = "json_utils::stringified_json::serialize",
596        deserialize_with = "json_utils::stringified_json::deserialize_maybe_stringified"
597    )]
598    pub arguments: serde_json::Value,
599}
600
601impl TryFrom<message::ToolResult> for Message {
602    type Error = message::MessageError;
603
604    fn try_from(value: message::ToolResult) -> Result<Self, Self::Error> {
605        let parts = value
606            .content
607            .into_iter()
608            .map(|content| match content {
609                message::ToolResultContent::Text(message::Text { text, .. }) => Ok(ToolResultContent::from(text)),
610                message::ToolResultContent::Json { value } => Ok(ToolResultContent::from(value.to_string())),
611                message::ToolResultContent::Image(_) => Err(message::MessageError::ConversionError(
612                    "OpenAI Chat Completions does not support images in tool results. Tool results must be text."
613                        .into(),
614                )),
615            })
616            .collect::<Result<Vec<_>, _>>()?;
617
618        let content = match parts.as_slice() {
619            [part] => ToolResultContentValue::String(part.text.clone()),
620            _ => ToolResultContentValue::Array(parts),
621        };
622
623        Ok(Message::ToolResult {
624            // `call_id` carries the provider-issued call id when it differs
625            // from the rig-level tool-result id (e.g. Mistral, llama.cpp).
626            tool_call_id: value.call_id.unwrap_or(value.id),
627            content,
628        })
629    }
630}
631
632impl TryFrom<message::UserContent> for UserContent {
633    type Error = message::MessageError;
634
635    fn try_from(value: message::UserContent) -> Result<Self, Self::Error> {
636        match value {
637            message::UserContent::Text(message::Text { text, .. }) => Ok(UserContent::Text { text }),
638            message::UserContent::Image(message::Image {
639                data,
640                detail,
641                media_type,
642                ..
643            }) => match data {
644                DocumentSourceKind::Url(url) => Ok(UserContent::Image {
645                    image_url: ImageUrl {
646                        url,
647                        // OpenAI's wire format always carries a detail level;
648                        // absent rig-level detail maps to the default (auto).
649                        detail: Some(detail.unwrap_or_default()),
650                    },
651                }),
652                DocumentSourceKind::Base64(data) => {
653                    let url = format!(
654                        "data:{};base64,{}",
655                        media_type.map(|i| i.to_mime_type()).ok_or(
656                            message::MessageError::ConversionError(
657                                "OpenAI Image URI must have media type".into()
658                            )
659                        )?,
660                        data
661                    );
662
663                    let detail = Some(detail.unwrap_or_default());
664
665                    Ok(UserContent::Image {
666                        image_url: ImageUrl { url, detail },
667                    })
668                }
669                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
670                    "Raw files not supported, encode as base64 first".into(),
671                )),
672                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
673                    "File IDs are not supported for images".into(),
674                )),
675                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
676                    "Document has no body".into(),
677                )),
678                doc => Err(message::MessageError::ConversionError(format!(
679                    "Unsupported document type: {doc:?}"
680                ))),
681            },
682            message::UserContent::Document(message::Document {
683                data: DocumentSourceKind::FileId(file_id),
684                ..
685            }) => Ok(UserContent::File {
686                file: FileData {
687                    file_data: None,
688                    file_id: Some(file_id),
689                    filename: None,
690                },
691            }),
692            message::UserContent::Document(message::Document {
693                data,
694                media_type: Some(message::DocumentMediaType::PDF),
695                ..
696            }) => match data {
697                DocumentSourceKind::Base64(b64) => Ok(UserContent::File {
698                    file: FileData {
699                        file_data: Some(format!("data:application/pdf;base64,{b64}")),
700                        file_id: None,
701                        filename: Some("document.pdf".to_string()),
702                    },
703                }),
704                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
705                    "OpenAI chat completions does not accept URL files; use the Responses API or pass base64-encoded bytes".into(),
706                )),
707                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
708                    "Raw files not supported, encode as base64 first".into(),
709                )),
710                DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
711                    "PDF documents must be base64-encoded, not raw strings".into(),
712                )),
713                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
714                    "File ID documents should be converted without media type constraints".into(),
715                )),
716                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
717                    "Document has no body".into(),
718                )),
719            },
720            message::UserContent::Document(message::Document { data, .. }) => {
721                if let DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text) = data {
722                    Ok(UserContent::Text { text })
723                } else {
724                    Err(message::MessageError::ConversionError(
725                        "Documents must be base64 or a string".into(),
726                    ))
727                }
728            }
729            message::UserContent::Audio(message::Audio {
730                data, media_type, ..
731            }) => match data {
732                DocumentSourceKind::Base64(data) => Ok(UserContent::Audio {
733                    input_audio: InputAudio {
734                        data,
735                        format: match media_type {
736                            Some(media_type) => media_type,
737                            None => AudioMediaType::MP3,
738                        },
739                    },
740                }),
741                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
742                    "URLs are not supported for audio".into(),
743                )),
744                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
745                    "Raw files are not supported for audio".into(),
746                )),
747                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
748                    "File IDs are not supported for audio".into(),
749                )),
750                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
751                    "Audio has no body".into(),
752                )),
753                audio => Err(message::MessageError::ConversionError(format!(
754                    "Unsupported audio type: {audio:?}"
755                ))),
756            },
757            message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
758                "Tool result is in unsupported format".into(),
759            )),
760            message::UserContent::Video(message::Video {
761                data, media_type, ..
762            }) => {
763                let url = match data {
764                    DocumentSourceKind::Url(url) => url,
765                    DocumentSourceKind::Base64(data) => {
766                        let mime = media_type
767                            .ok_or_else(|| {
768                                message::MessageError::ConversionError(
769                                    "Video media type required for base64 encoding".into(),
770                                )
771                            })?
772                            .to_mime_type();
773                        format!("data:{mime};base64,{data}")
774                    }
775                    DocumentSourceKind::Raw(_) => {
776                        return Err(message::MessageError::ConversionError(
777                            "Raw bytes not supported for video, encode as base64 first".into(),
778                        ));
779                    }
780                    DocumentSourceKind::FileId(_) => {
781                        return Err(message::MessageError::ConversionError(
782                            "File IDs are not supported for video".into(),
783                        ));
784                    }
785                    DocumentSourceKind::String(_) => {
786                        return Err(message::MessageError::ConversionError(
787                            "String source not supported for video".into(),
788                        ));
789                    }
790                    DocumentSourceKind::Unknown => {
791                        return Err(message::MessageError::ConversionError(
792                            "Video has no data".into(),
793                        ));
794                    }
795                };
796                Ok(UserContent::Video {
797                    video_url: VideoUrl { url },
798                })
799            }
800        }
801    }
802}
803
804impl TryFrom<OneOrMany<message::UserContent>> for Vec<Message> {
805    type Error = message::MessageError;
806
807    fn try_from(value: OneOrMany<message::UserContent>) -> Result<Self, Self::Error> {
808        fn flush_user_content(
809            messages: &mut Vec<Message>,
810            pending: &mut Vec<UserContent>,
811        ) -> Result<(), message::MessageError> {
812            if pending.is_empty() {
813                return Ok(());
814            }
815
816            let content = OneOrMany::many(std::mem::take(pending)).map_err(|_| {
817                message::MessageError::ConversionError(
818                    "OpenAI user message did not contain any non-tool content".into(),
819                )
820            })?;
821            messages.push(Message::User {
822                content,
823                name: None,
824            });
825            Ok(())
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
846impl TryFrom<OneOrMany<message::AssistantContent>> for Vec<Message> {
847    type Error = message::MessageError;
848
849    fn try_from(value: OneOrMany<message::AssistantContent>) -> Result<Self, Self::Error> {
850        let mut text_content = Vec::new();
851        let mut tool_calls = Vec::new();
852        // Distinct reasoning blocks are joined with a newline (matching
853        // `display_text()`'s own inter-block separator) rather than glued
854        // together, so replayed multi-block reasoning keeps its boundaries.
855        let mut reasoning_parts: Vec<String> = Vec::new();
856
857        for content in value {
858            match content {
859                message::AssistantContent::Text(text) => text_content.push(text),
860                message::AssistantContent::ToolCall(tool_call) => tool_calls.push(tool_call),
861                message::AssistantContent::Reasoning(reasoning) => {
862                    let display = reasoning.display_text();
863                    if !display.is_empty() {
864                        reasoning_parts.push(display);
865                    }
866                }
867                message::AssistantContent::Image(_) => {
868                    return Err(message::MessageError::ConversionError(
869                        "OpenAI assistant messages do not support image content in chat completions"
870                            .into(),
871                    ));
872                }
873            }
874        }
875
876        if text_content.is_empty() && tool_calls.is_empty() {
877            return Ok(vec![]);
878        }
879
880        Ok(vec![Message::Assistant {
881            content: text_content
882                .into_iter()
883                .map(|content| content.text.into())
884                .collect::<Vec<_>>(),
885            reasoning: if reasoning_parts.is_empty() {
886                None
887            } else {
888                Some(reasoning_parts.join("\n"))
889            },
890            refusal: None,
891            audio: None,
892            name: None,
893            tool_calls: tool_calls
894                .into_iter()
895                .map(|tool_call| tool_call.into())
896                .collect::<Vec<_>>(),
897            reasoning_details: Vec::new(),
898            images: Vec::new(),
899        }])
900    }
901}
902
903impl TryFrom<message::Message> for Vec<Message> {
904    type Error = message::MessageError;
905
906    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
907        match message {
908            message::Message::System { content } => Ok(vec![Message::system(&content)]),
909            message::Message::User { content } => content.try_into(),
910            message::Message::Assistant { content, .. } => content.try_into(),
911        }
912    }
913}
914
915impl From<message::ToolCall> for ToolCall {
916    fn from(tool_call: message::ToolCall) -> Self {
917        Self {
918            // Keep the assistant echo consistent with the tool-result side,
919            // which prefers the provider-issued `call_id` over the rig-level
920            // id (e.g. Responses-API history replayed via chat completions).
921            id: tool_call.call_id.unwrap_or(tool_call.id),
922            r#type: ToolType::default(),
923            function: Function {
924                name: tool_call.function.name,
925                arguments: tool_call.function.arguments,
926            },
927        }
928    }
929}
930
931impl From<ToolCall> for message::ToolCall {
932    fn from(tool_call: ToolCall) -> Self {
933        Self {
934            id: tool_call.id,
935            call_id: None,
936            function: message::ToolFunction {
937                name: tool_call.function.name,
938                arguments: tool_call.function.arguments,
939            },
940            signature: None,
941            additional_params: None,
942        }
943    }
944}
945
946impl TryFrom<Message> for message::Message {
947    type Error = message::MessageError;
948
949    fn try_from(message: Message) -> Result<Self, Self::Error> {
950        Ok(match message {
951            Message::User { content, .. } => message::Message::User {
952                content: content.map(|content| content.into()),
953            },
954            Message::Assistant {
955                content,
956                tool_calls,
957                reasoning,
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                assistant_content.extend(content.into_iter().map(|content| match content {
969                    AssistantContent::Text { text, .. } => message::AssistantContent::text(text),
970                    AssistantContent::Refusal { refusal } => {
971                        message::AssistantContent::text(refusal)
972                    }
973                }));
974
975                assistant_content.extend(
976                    tool_calls
977                        .into_iter()
978                        .map(|tool_call| Ok(message::AssistantContent::ToolCall(tool_call.into())))
979                        .collect::<Result<Vec<_>, _>>()?,
980                );
981
982                message::Message::Assistant {
983                    id: None,
984                    content: OneOrMany::many(assistant_content).map_err(|_| {
985                        message::MessageError::ConversionError(
986                            "Neither `content` nor `tool_calls` was provided to the Message"
987                                .to_owned(),
988                        )
989                    })?,
990                }
991            }
992
993            Message::ToolResult {
994                tool_call_id,
995                content,
996            } => message::Message::User {
997                content: OneOrMany::one(message::UserContent::tool_result(
998                    tool_call_id,
999                    OneOrMany::one(message::ToolResultContent::text(content.as_text())),
1000                )),
1001            },
1002
1003            // System messages should get stripped out when converting messages, this is just a
1004            // stop gap to avoid obnoxious error handling or panic occurring.
1005            Message::System { content, .. } => message::Message::User {
1006                content: content.map(|content| message::UserContent::text(content.text)),
1007            },
1008        })
1009    }
1010}
1011
1012impl From<UserContent> for message::UserContent {
1013    fn from(content: UserContent) -> Self {
1014        match content {
1015            UserContent::Text { text, .. } => message::UserContent::text(text),
1016            UserContent::Image { image_url } => {
1017                message::UserContent::image_url(image_url.url, None, image_url.detail)
1018            }
1019            UserContent::Audio { input_audio } => {
1020                message::UserContent::audio(input_audio.data, Some(input_audio.format))
1021            }
1022            UserContent::File {
1023                file: FileData {
1024                    file_data, file_id, ..
1025                },
1026            } => match file_data {
1027                Some(data_url) => {
1028                    let kind = match data_url.strip_prefix("data:application/pdf;base64,") {
1029                        Some(b64) => DocumentSourceKind::Base64(b64.to_string()),
1030                        None => DocumentSourceKind::String(data_url),
1031                    };
1032                    message::UserContent::Document(message::Document {
1033                        data: kind,
1034                        media_type: Some(message::DocumentMediaType::PDF),
1035                        additional_params: None,
1036                    })
1037                }
1038                None => match file_id {
1039                    Some(id) => message::UserContent::Document(message::Document {
1040                        data: DocumentSourceKind::FileId(id),
1041                        media_type: None,
1042                        additional_params: None,
1043                    }),
1044                    None => message::UserContent::text(String::new()),
1045                },
1046            },
1047            UserContent::Video { video_url } => {
1048                let decomposed = video_url
1049                    .url
1050                    .strip_prefix("data:")
1051                    .and_then(|rest| rest.split_once(";base64,"))
1052                    .and_then(|(mime, data)| {
1053                        // Only decompose data URIs whose media type survives
1054                        // the round trip; unrecognized MIMEs (e.g.
1055                        // video/quicktime, parameterized types) stay as URLs
1056                        // so re-serialization reproduces the original URI.
1057                        crate::message::VideoMediaType::from_mime_type(mime)
1058                            .map(|media_type| (media_type, data))
1059                    });
1060                match decomposed {
1061                    Some((media_type, data)) => message::UserContent::video(data, Some(media_type)),
1062                    None => message::UserContent::video_url(video_url.url, None),
1063                }
1064            }
1065        }
1066    }
1067}
1068
1069impl From<String> for UserContent {
1070    fn from(s: String) -> Self {
1071        UserContent::Text { text: s }
1072    }
1073}
1074
1075impl From<&str> for UserContent {
1076    fn from(s: &str) -> Self {
1077        UserContent::Text {
1078            text: s.to_string(),
1079        }
1080    }
1081}
1082
1083impl FromStr for UserContent {
1084    type Err = Infallible;
1085
1086    fn from_str(s: &str) -> Result<Self, Self::Err> {
1087        Ok(UserContent::Text {
1088            text: s.to_string(),
1089        })
1090    }
1091}
1092
1093impl From<String> for AssistantContent {
1094    fn from(s: String) -> Self {
1095        AssistantContent::Text { text: s }
1096    }
1097}
1098
1099impl FromStr for AssistantContent {
1100    type Err = Infallible;
1101
1102    fn from_str(s: &str) -> Result<Self, Self::Err> {
1103        Ok(AssistantContent::Text {
1104            text: s.to_string(),
1105        })
1106    }
1107}
1108impl From<String> for SystemContent {
1109    fn from(s: String) -> Self {
1110        SystemContent {
1111            r#type: SystemContentType::default(),
1112            text: s,
1113        }
1114    }
1115}
1116
1117impl FromStr for SystemContent {
1118    type Err = Infallible;
1119
1120    fn from_str(s: &str) -> Result<Self, Self::Err> {
1121        Ok(SystemContent {
1122            r#type: SystemContentType::default(),
1123            text: s.to_string(),
1124        })
1125    }
1126}
1127
1128#[derive(Debug, Deserialize, Serialize)]
1129pub struct CompletionResponse {
1130    pub id: String,
1131    // Defaulted on deserialization: some OpenAI-compatible gateways
1132    // (HuggingFace router sub-providers, TGI variants) omit them.
1133    #[serde(default)]
1134    pub object: String,
1135    #[serde(default)]
1136    pub created: u64,
1137    pub model: String,
1138    pub system_fingerprint: Option<String>,
1139    pub choices: Vec<Choice>,
1140    pub usage: Option<Usage>,
1141}
1142
1143impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
1144    type Error = CompletionError;
1145
1146    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
1147        let choice = response.choices.first().ok_or_else(|| {
1148            CompletionError::ResponseError("Response contained no choices".to_owned())
1149        })?;
1150
1151        let content = match &choice.message {
1152            Message::Assistant {
1153                content,
1154                tool_calls,
1155                reasoning,
1156                ..
1157            } => {
1158                let mut content = content
1159                    .iter()
1160                    .filter_map(|c| {
1161                        let s = match c {
1162                            AssistantContent::Text { text, .. } => text,
1163                            AssistantContent::Refusal { refusal } => refusal,
1164                        };
1165                        if s.is_empty() {
1166                            None
1167                        } else {
1168                            Some(completion::AssistantContent::text(s))
1169                        }
1170                    })
1171                    .collect::<Vec<_>>();
1172
1173                if let Some(reasoning) = reasoning {
1174                    // llama.cpp exposes hidden reasoning on a separate non-standard field.
1175                    // Keep it structured here so the non-streaming path matches streaming
1176                    // behavior and does not pollute plain-text response surfaces.
1177                    content.push(completion::AssistantContent::reasoning(reasoning));
1178                }
1179
1180                content.extend(
1181                    tool_calls
1182                        .iter()
1183                        .map(|call| {
1184                            completion::AssistantContent::tool_call(
1185                                &call.id,
1186                                &call.function.name,
1187                                call.function.arguments.clone(),
1188                            )
1189                        })
1190                        .collect::<Vec<_>>(),
1191                );
1192                Ok(content)
1193            }
1194            _ => Err(CompletionError::ResponseError(
1195                "Response did not contain a valid message or tool call".into(),
1196            )),
1197        }?;
1198
1199        let choice = OneOrMany::many(content).map_err(|_| {
1200            CompletionError::ResponseError(
1201                "Response contained no message or tool call (empty)".to_owned(),
1202            )
1203        })?;
1204
1205        let usage = response
1206            .usage
1207            .as_ref()
1208            .map(GetTokenUsage::token_usage)
1209            .unwrap_or_default();
1210
1211        Ok(completion::CompletionResponse {
1212            choice,
1213            usage,
1214            raw_response: response,
1215            message_id: None,
1216        })
1217    }
1218}
1219
1220impl ProviderResponseExt for CompletionResponse {
1221    type OutputMessage = Choice;
1222    type Usage = Usage;
1223
1224    fn get_response_id(&self) -> Option<String> {
1225        Some(self.id.to_owned())
1226    }
1227
1228    fn get_response_model_name(&self) -> Option<String> {
1229        Some(self.model.to_owned())
1230    }
1231
1232    fn get_output_messages(&self) -> Vec<Self::OutputMessage> {
1233        self.choices.clone()
1234    }
1235
1236    fn get_text_response(&self) -> Option<String> {
1237        let response = self
1238            .choices
1239            .iter()
1240            .filter_map(|choice| assistant_message_text_response(&choice.message))
1241            .collect::<Vec<_>>()
1242            .join("\n");
1243
1244        if response.is_empty() {
1245            None
1246        } else {
1247            Some(response)
1248        }
1249    }
1250
1251    fn get_usage(&self) -> Option<Self::Usage> {
1252        self.usage.clone()
1253    }
1254}
1255
1256fn assistant_message_text_response(message: &Message) -> Option<String> {
1257    let Message::Assistant {
1258        content, refusal, ..
1259    } = message
1260    else {
1261        return None;
1262    };
1263
1264    let mut segments = content
1265        .iter()
1266        .filter_map(|content| match content {
1267            AssistantContent::Text { text, .. } => (!text.is_empty()).then(|| text.clone()),
1268            AssistantContent::Refusal { refusal } => (!refusal.is_empty()).then(|| refusal.clone()),
1269        })
1270        .collect::<Vec<_>>();
1271
1272    if segments.is_empty()
1273        && let Some(refusal) = refusal.as_ref().filter(|refusal| !refusal.is_empty())
1274    {
1275        segments.push(refusal.clone());
1276    }
1277
1278    if segments.is_empty() {
1279        None
1280    } else {
1281        Some(segments.join("\n"))
1282    }
1283}
1284
1285#[derive(Clone, Debug, Serialize, Deserialize)]
1286pub struct Choice {
1287    pub index: usize,
1288    pub message: Message,
1289    pub logprobs: Option<serde_json::Value>,
1290    pub finish_reason: String,
1291}
1292
1293#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1294pub struct PromptTokensDetails {
1295    /// Cached tokens from prompt caching
1296    #[serde(default)]
1297    pub cached_tokens: usize,
1298}
1299
1300#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1301pub struct CompletionTokensDetails {
1302    /// Reasoning tokens reported by reasoning-capable providers.
1303    #[serde(default)]
1304    pub reasoning_tokens: usize,
1305}
1306
1307#[derive(Clone, Debug, Deserialize, Serialize)]
1308pub struct Usage {
1309    pub prompt_tokens: usize,
1310    #[serde(default, skip_serializing_if = "Option::is_none")]
1311    pub completion_tokens: Option<usize>,
1312    pub total_tokens: usize,
1313    #[serde(skip_serializing_if = "Option::is_none")]
1314    pub prompt_tokens_details: Option<PromptTokensDetails>,
1315    #[serde(default, skip_serializing_if = "Option::is_none")]
1316    pub completion_tokens_details: Option<CompletionTokensDetails>,
1317    #[serde(default, skip_serializing_if = "Option::is_none")]
1318    pub queue_time: Option<f64>,
1319    #[serde(default, skip_serializing_if = "Option::is_none")]
1320    pub prompt_time: Option<f64>,
1321    #[serde(default, skip_serializing_if = "Option::is_none")]
1322    pub completion_time: Option<f64>,
1323    #[serde(default, skip_serializing_if = "Option::is_none")]
1324    pub total_time: Option<f64>,
1325}
1326
1327impl Usage {
1328    pub fn new() -> Self {
1329        Self {
1330            prompt_tokens: 0,
1331            completion_tokens: None,
1332            total_tokens: 0,
1333            prompt_tokens_details: None,
1334            completion_tokens_details: None,
1335            queue_time: None,
1336            prompt_time: None,
1337            completion_time: None,
1338            total_time: None,
1339        }
1340    }
1341}
1342
1343impl Default for Usage {
1344    fn default() -> Self {
1345        Self::new()
1346    }
1347}
1348
1349impl fmt::Display for Usage {
1350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1351        let Usage {
1352            prompt_tokens,
1353            total_tokens,
1354            ..
1355        } = self;
1356        write!(
1357            f,
1358            "Prompt tokens: {prompt_tokens} Total tokens: {total_tokens}"
1359        )
1360    }
1361}
1362
1363impl GetTokenUsage for Usage {
1364    fn token_usage(&self) -> crate::completion::Usage {
1365        let mut usage = crate::providers::internal::completion_usage(
1366            self.prompt_tokens as u64,
1367            self.completion_tokens
1368                .unwrap_or_else(|| self.total_tokens.saturating_sub(self.prompt_tokens))
1369                as u64,
1370            self.total_tokens as u64,
1371            self.prompt_tokens_details
1372                .as_ref()
1373                .map(|d| d.cached_tokens as u64)
1374                .unwrap_or(0),
1375        );
1376        usage.reasoning_tokens = self
1377            .completion_tokens_details
1378            .as_ref()
1379            .map(|d| d.reasoning_tokens as u64)
1380            .unwrap_or(0);
1381        usage
1382    }
1383}
1384
1385/// Per-model options that affect request conversion/finalization for the shared
1386/// OpenAI-compatible chat-completions path.
1387#[derive(Debug, Clone, Copy, Default)]
1388pub struct CompletionModelOptions {
1389    /// Whether tool schemas should be sanitized for strict-mode validation.
1390    pub strict_tools: bool,
1391    /// Whether tool-result messages should serialize their content as arrays.
1392    pub tool_result_array_content: bool,
1393    /// Whether the model requested provider-specific prompt caching markers.
1394    pub prompt_caching: bool,
1395}
1396
1397/// Contract for provider extensions that speak the OpenAI Chat Completions wire
1398/// format through [`GenericCompletionModel`]. Mirrors
1399/// [`AnthropicCompatibleProvider`](crate::providers::anthropic::completion::AnthropicCompatibleProvider)
1400/// on the Anthropic-compatible side.
1401///
1402/// Request construction runs the hooks in a fixed order:
1403/// [`prepare_request`](Self::prepare_request) on the typed request, then
1404/// serialization, then (for streaming) the `stream`/`stream_options` merge,
1405/// and finally
1406/// [`finalize_request_body_with_options`](Self::finalize_request_body_with_options)
1407/// on the serialized body — so the finalize hook always sees the streaming
1408/// parameters and model-level options.
1409pub trait OpenAICompatibleProvider: crate::client::Provider {
1410    /// Provider name recorded on `gen_ai.provider.name` telemetry spans.
1411    const PROVIDER_NAME: &'static str;
1412
1413    /// Whether the backend can emit a whole tool call (id, name, and complete
1414    /// arguments) in a single streaming chunk, as llama.cpp-based servers do.
1415    /// When true, the shared streaming layer emits such calls as soon as they
1416    /// arrive instead of holding them until the stream ends.
1417    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = false;
1418
1419    /// Whether the provider supports tool calling. When false, `tools` and
1420    /// `tool_choice` are dropped with a warning during request conversion —
1421    /// before tool-choice validation, so unsupported tool configurations
1422    /// never error client-side on a provider that ignores tools anyway.
1423    const SUPPORTS_TOOLS: bool = true;
1424
1425    /// Whether `output_schema` maps to OpenAI's `response_format`. Providers
1426    /// whose APIs reject `json_schema` response formats set this to false;
1427    /// the schema is then dropped with a warning instead of being sent.
1428    const SUPPORTS_RESPONSE_FORMAT: bool = true;
1429
1430    /// Whether streaming requests include
1431    /// `"stream_options": {"include_usage": true}`. Providers that reject
1432    /// unknown parameters and already report usage on the final chunk set
1433    /// this to false.
1434    const STREAM_INCLUDE_USAGE: bool = true;
1435
1436    /// The usage payload parsed from streaming chunks and carried on the
1437    /// final streaming response. OpenAI's [`Usage`] for most providers;
1438    /// providers with richer usage accounting (e.g. Mistral's cached-token
1439    /// fallbacks, DeepSeek's cache hit/miss counters) substitute their own.
1440    type StreamingUsage: Clone
1441        + Default
1442        + GetTokenUsage
1443        + Serialize
1444        + serde::de::DeserializeOwned
1445        + Unpin
1446        + WasmCompatSend
1447        + WasmCompatSync
1448        + 'static;
1449
1450    /// The chat-completions payload this provider returns.
1451    type Response: serde::de::DeserializeOwned
1452        + Serialize
1453        + crate::telemetry::ProviderResponseExt<Usage: GetTokenUsage>
1454        + TryInto<completion::CompletionResponse<Self::Response>, Error = CompletionError>
1455        + WasmCompatSend
1456        + WasmCompatSync;
1457
1458    /// The request path for chat completions, resolved against the client
1459    /// base URL by [`Provider::build_uri`](crate::client::Provider::build_uri).
1460    /// Providers that route the model through the URL (e.g. Azure deployment
1461    /// paths) or keep other capabilities on differently-versioned paths
1462    /// override this. `model` is the identifier the completion model handle
1463    /// was created with; per-request model overrides only affect the body.
1464    fn completion_path(&self, model: &str) -> String {
1465        let _ = model;
1466        "/chat/completions".to_string()
1467    }
1468
1469    /// Build the typed chat-completions request. Providers that share the
1470    /// OpenAI transport but need provider-specific message conversion can
1471    /// override this while still using [`GenericCompletionModel`] for sending,
1472    /// streaming, error handling, and telemetry.
1473    fn build_completion_request(
1474        &self,
1475        model: String,
1476        request: CoreCompletionRequest,
1477        options: CompletionModelOptions,
1478    ) -> Result<CompletionRequest, CompletionError> {
1479        CompletionRequest::try_from(OpenAIRequestParams {
1480            model,
1481            request,
1482            strict_tools: options.strict_tools,
1483            tool_result_array_content: options.tool_result_array_content,
1484            supports_response_format: Self::SUPPORTS_RESPONSE_FORMAT,
1485            supports_tools: Self::SUPPORTS_TOOLS,
1486        })
1487    }
1488
1489    /// Adjust the typed request before serialization (e.g. rewrite the model
1490    /// identifier or fold provider-native tool definitions out of
1491    /// `additional_params`).
1492    fn prepare_request(&self, request: &mut CompletionRequest) -> Result<(), CompletionError> {
1493        let _ = request;
1494        Ok(())
1495    }
1496
1497    /// Adjust the fully serialized request body — after any streaming
1498    /// parameters are merged — immediately before it is sent. This is where
1499    /// wire-level dialect differences live (e.g. Mistral's `"any"` tool
1500    /// choice, DeepSeek's string-flattened message content).
1501    fn finalize_request_body(&self, body: &mut serde_json::Value) -> Result<(), CompletionError> {
1502        let _ = body;
1503        Ok(())
1504    }
1505
1506    /// Adjust the fully serialized request body with model-level options.
1507    /// Providers that do not need model-instance options should override
1508    /// [`finalize_request_body`](Self::finalize_request_body) instead.
1509    fn finalize_request_body_with_options(
1510        &self,
1511        body: &mut serde_json::Value,
1512        options: CompletionModelOptions,
1513    ) -> Result<(), CompletionError> {
1514        let _ = options;
1515        self.finalize_request_body(body)
1516    }
1517
1518    /// Decorate streamed tool calls from provider-specific streaming detail
1519    /// payloads. Most OpenAI-compatible providers do not emit such details.
1520    fn decorate_streaming_tool_call(
1521        &self,
1522        detail: &serde_json::Value,
1523        tool_calls: &mut std::collections::HashMap<usize, crate::streaming::RawStreamingToolCall>,
1524    ) {
1525        let _ = (detail, tool_calls);
1526    }
1527}
1528
1529impl OpenAICompatibleProvider for super::OpenAICompletionsExt {
1530    const PROVIDER_NAME: &'static str = "openai";
1531
1532    type StreamingUsage = Usage;
1533    type Response = CompletionResponse;
1534}
1535
1536/// A chat-completions model over any [`OpenAICompatibleProvider`] extension.
1537/// This is the advertised path for OpenAI-compatible providers; see the
1538/// provider checklist in [`crate::providers`].
1539#[derive(Clone)]
1540pub struct GenericCompletionModel<Ext = super::OpenAICompletionsExt, H = reqwest::Client> {
1541    pub(crate) client: crate::client::Client<Ext, H>,
1542    pub model: String,
1543    pub(crate) strict_tools: bool,
1544    pub(crate) tool_result_array_content: bool,
1545    pub(crate) prompt_caching: bool,
1546}
1547
1548/// The completion model struct for OpenAI's Chat Completions API.
1549///
1550/// This preserves the historical public generic shape where the first generic
1551/// parameter is the HTTP client type.
1552pub type CompletionModel<H = reqwest::Client> =
1553    GenericCompletionModel<super::OpenAICompletionsExt, H>;
1554
1555impl<Ext, H> GenericCompletionModel<Ext, H>
1556where
1557    crate::client::Client<Ext, H>: std::fmt::Debug + Clone + 'static,
1558    Ext: crate::client::Provider + Clone + 'static,
1559{
1560    pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1561        Self {
1562            client,
1563            model: model.into(),
1564            strict_tools: false,
1565            tool_result_array_content: false,
1566            prompt_caching: false,
1567        }
1568    }
1569
1570    /// Enable strict mode for tool schemas.
1571    ///
1572    /// When enabled, tool schemas are automatically sanitized to meet OpenAI's strict mode requirements:
1573    /// - `additionalProperties: false` is added to all objects
1574    /// - All properties are marked as required
1575    /// - `strict: true` is set on each function definition
1576    ///
1577    /// This allows OpenAI to guarantee that the model's tool calls will match the schema exactly.
1578    pub fn with_strict_tools(mut self) -> Self {
1579        self.strict_tools = true;
1580        self
1581    }
1582
1583    pub fn with_tool_result_array_content(mut self) -> Self {
1584        self.tool_result_array_content = true;
1585        self
1586    }
1587}
1588
1589#[derive(Debug, Serialize, Deserialize, Clone)]
1590pub struct CompletionRequest {
1591    pub model: String,
1592    pub messages: Vec<Message>,
1593    #[serde(skip_serializing_if = "Vec::is_empty")]
1594    pub tools: Vec<ToolDefinition>,
1595    #[serde(skip_serializing_if = "Option::is_none")]
1596    pub tool_choice: Option<ToolChoice>,
1597    #[serde(skip_serializing_if = "Option::is_none")]
1598    pub temperature: Option<f64>,
1599    #[serde(skip_serializing_if = "Option::is_none")]
1600    pub max_tokens: Option<u64>,
1601    #[serde(flatten)]
1602    pub additional_params: Option<serde_json::Value>,
1603}
1604
1605/// Shared helper for provider `finalize_request_body` hooks whose APIs take
1606/// message `content` as a plain string: flattens a content-part array to the
1607/// concatenation of its text parts. When `only_if_all_text` is set, arrays
1608/// containing non-text parts are left untouched (for APIs with their own
1609/// multimodal handling); otherwise non-text parts are dropped.
1610pub(crate) fn flatten_text_content_parts(
1611    content: &mut serde_json::Value,
1612    separator: &str,
1613    only_if_all_text: bool,
1614) {
1615    // Refusals are textual content too; flatten them alongside text parts.
1616    // Checked per key so a null-padded `text` next to a string `refusal`
1617    // still counts as textual.
1618    fn part_text(part: &serde_json::Value) -> Option<&str> {
1619        part.get("text")
1620            .and_then(serde_json::Value::as_str)
1621            .or_else(|| part.get("refusal").and_then(serde_json::Value::as_str))
1622    }
1623
1624    let Some(parts) = content.as_array() else {
1625        return;
1626    };
1627    if only_if_all_text && !parts.iter().all(|part| part_text(part).is_some()) {
1628        return;
1629    }
1630    let mut flattened = String::new();
1631    for text in parts.iter().filter_map(part_text) {
1632        if !flattened.is_empty() {
1633            flattened.push_str(separator);
1634        }
1635        flattened.push_str(text);
1636    }
1637    *content = serde_json::Value::String(flattened);
1638}
1639
1640/// Joins the `text` fields of `type == "text"` content parts, in order.
1641pub(crate) fn joined_text_parts(parts: &[serde_json::Value]) -> String {
1642    parts
1643        .iter()
1644        .filter_map(|part| {
1645            (part.get("type").and_then(serde_json::Value::as_str) == Some("text"))
1646                .then(|| part.get("text").and_then(serde_json::Value::as_str))
1647                .flatten()
1648        })
1649        .collect::<Vec<_>>()
1650        .join("")
1651}
1652
1653/// Shared helper for provider `finalize_request_body` hooks whose APIs only
1654/// accept plain `{role, content}` chat messages: removes tool-exchange
1655/// remnants left in shared histories (role `tool` messages, assistant
1656/// `tool_calls`/`reasoning_content`), optionally flattens content-part arrays
1657/// to strings, and drops assistant turns left without content (pure
1658/// tool-call scaffolding). With `merge_same_role`, consecutive same-role
1659/// string-content messages are additionally merged — the removals can leave
1660/// user/user as well as assistant/assistant adjacency, and alternation-strict
1661/// APIs (Perplexity) reject both; providers without that constraint keep
1662/// their turns separate.
1663pub(crate) fn sanitize_plain_text_history(
1664    messages: &mut Vec<serde_json::Value>,
1665    flatten: Option<(&str, bool)>,
1666    strip_names: bool,
1667    merge_same_role: bool,
1668) {
1669    messages
1670        .retain(|message| message.get("role").and_then(serde_json::Value::as_str) != Some("tool"));
1671
1672    for message in messages.iter_mut() {
1673        let Some(object) = message.as_object_mut() else {
1674            continue;
1675        };
1676        if object.get("role").and_then(serde_json::Value::as_str) == Some("assistant") {
1677            object.remove("tool_calls");
1678            object.remove("reasoning_content");
1679        }
1680        if strip_names {
1681            object.remove("name");
1682        }
1683        if let Some((separator, only_if_all_text)) = flatten
1684            && let Some(content) = object.get_mut("content")
1685        {
1686            flatten_text_content_parts(content, separator, only_if_all_text);
1687        }
1688    }
1689
1690    messages.retain(|message| {
1691        if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
1692            return true;
1693        }
1694        match message.get("content") {
1695            Some(serde_json::Value::String(text)) => !text.is_empty(),
1696            Some(serde_json::Value::Null) | None => false,
1697            Some(_) => true,
1698        }
1699    });
1700
1701    if !merge_same_role {
1702        return;
1703    }
1704
1705    let mut merged: Vec<serde_json::Value> = Vec::with_capacity(messages.len());
1706    for message in std::mem::take(messages) {
1707        let merged_text = if let Some(role) = message
1708            .get("role")
1709            .and_then(serde_json::Value::as_str)
1710            .filter(|role| matches!(*role, "assistant" | "user"))
1711            && let Some(previous) = merged.last()
1712            && previous.get("role").and_then(serde_json::Value::as_str) == Some(role)
1713            && let Some(previous_text) = previous.get("content").and_then(serde_json::Value::as_str)
1714            && let Some(text) = message.get("content").and_then(serde_json::Value::as_str)
1715        {
1716            Some(format!("{previous_text}\n{text}"))
1717        } else {
1718            None
1719        };
1720
1721        if let Some(text) = merged_text
1722            && let Some(previous) = merged.last_mut().and_then(serde_json::Value::as_object_mut)
1723        {
1724            previous.insert("content".to_string(), serde_json::Value::String(text));
1725            continue;
1726        }
1727        merged.push(message);
1728    }
1729    *messages = merged;
1730}
1731
1732pub struct OpenAIRequestParams {
1733    pub model: String,
1734    pub request: CoreCompletionRequest,
1735    pub strict_tools: bool,
1736    pub tool_result_array_content: bool,
1737    /// Maps `output_schema` to `response_format` when true; drops it with a
1738    /// warning when false (providers whose APIs reject `json_schema`).
1739    pub supports_response_format: bool,
1740    /// Serializes `tools`/`tool_choice` when true; drops them with a warning
1741    /// when false (providers without tool-calling support).
1742    pub supports_tools: bool,
1743}
1744
1745impl TryFrom<OpenAIRequestParams> for CompletionRequest {
1746    type Error = CompletionError;
1747
1748    fn try_from(params: OpenAIRequestParams) -> Result<Self, Self::Error> {
1749        let OpenAIRequestParams {
1750            model,
1751            request: req,
1752            strict_tools,
1753            tool_result_array_content,
1754            supports_response_format,
1755            supports_tools,
1756        } = params;
1757        let chat_history = req.chat_history_with_documents();
1758
1759        let CoreCompletionRequest {
1760            model: request_model,
1761            preamble,
1762            chat_history: _,
1763            tools,
1764            temperature,
1765            max_tokens,
1766            additional_params,
1767            tool_choice,
1768            output_schema,
1769            ..
1770        } = req;
1771
1772        let mut partial_history = Vec::new();
1773        partial_history.extend(chat_history);
1774
1775        let mut full_history: Vec<Message> =
1776            preamble.map_or_else(Vec::new, |preamble| vec![Message::system(&preamble)]);
1777
1778        full_history.extend(
1779            partial_history
1780                .into_iter()
1781                .map(message::Message::try_into)
1782                .collect::<Result<Vec<Vec<Message>>, _>>()?
1783                .into_iter()
1784                .flatten()
1785                .collect::<Vec<_>>(),
1786        );
1787
1788        if full_history.is_empty() {
1789            return Err(CompletionError::RequestError(
1790                std::io::Error::new(
1791                    std::io::ErrorKind::InvalidInput,
1792                    "OpenAI Chat Completions request has no provider-compatible messages after conversion",
1793                )
1794                .into(),
1795            ));
1796        }
1797
1798        for msg in &mut full_history {
1799            if let Message::ToolResult { content, .. } = msg {
1800                let normalized = if tool_result_array_content {
1801                    content.to_array()
1802                } else {
1803                    ToolResultContentValue::String(content.as_text())
1804                };
1805
1806                *content = normalized;
1807            }
1808        }
1809
1810        let history_has_tool_result = history_contains_tool_result(&full_history);
1811
1812        let (tools, tool_choice) = if supports_tools {
1813            let tool_choice = tool_choice.map(ToolChoice::try_from).transpose()?;
1814            let tools: Vec<ToolDefinition> = tools
1815                .into_iter()
1816                .map(|tool| {
1817                    let def = ToolDefinition::from(tool);
1818                    if strict_tools { def.with_strict() } else { def }
1819                })
1820                .collect();
1821            (tools, tool_choice)
1822        } else {
1823            if !tools.is_empty() {
1824                tracing::warn!("Tool use is not supported by this provider; tools will be ignored");
1825            }
1826            if tool_choice.is_some() {
1827                tracing::warn!("Tool choice is not supported by this provider and will be ignored");
1828            }
1829            (Vec::new(), None)
1830        };
1831
1832        if output_schema.is_some() && !supports_response_format {
1833            tracing::warn!(
1834                "Structured outputs are not supported by this provider; ignoring output_schema"
1835            );
1836        }
1837
1838        // Some OpenAI-compatible backends such as llama.cpp will skip tool execution
1839        // if `response_format` is sent on the first turn alongside tools. Delay the
1840        // schema until after the conversation contains a tool result.
1841        let should_apply_response_format = output_schema.is_some()
1842            && supports_response_format
1843            && (tools.is_empty() || history_has_tool_result);
1844
1845        // Map output_schema to OpenAI's response_format and merge into additional_params
1846        let additional_params = if let Some(schema) = output_schema
1847            && should_apply_response_format
1848        {
1849            let name = schema
1850                .as_object()
1851                .and_then(|o| o.get("title"))
1852                .and_then(|v| v.as_str())
1853                .unwrap_or("response_schema")
1854                .to_string();
1855            let mut schema_value = schema.to_value();
1856            super::sanitize_schema(&mut schema_value);
1857            let response_format = serde_json::json!({
1858                "response_format": {
1859                    "type": "json_schema",
1860                    "json_schema": {
1861                        "name": name,
1862                        "strict": true,
1863                        "schema": schema_value
1864                    }
1865                }
1866            });
1867            Some(match additional_params {
1868                Some(existing) => json_utils::merge(existing, response_format),
1869                None => response_format,
1870            })
1871        } else {
1872            additional_params
1873        };
1874
1875        let res = Self {
1876            model: request_model.unwrap_or(model),
1877            messages: full_history,
1878            tools,
1879            tool_choice,
1880            temperature,
1881            max_tokens,
1882            additional_params,
1883        };
1884
1885        Ok(res)
1886    }
1887}
1888
1889impl TryFrom<(String, CoreCompletionRequest)> for CompletionRequest {
1890    type Error = CompletionError;
1891
1892    fn try_from((model, req): (String, CoreCompletionRequest)) -> Result<Self, Self::Error> {
1893        CompletionRequest::try_from(OpenAIRequestParams {
1894            model,
1895            request: req,
1896            strict_tools: false,
1897            tool_result_array_content: false,
1898            supports_response_format: true,
1899            supports_tools: true,
1900        })
1901    }
1902}
1903
1904impl<Ext, H> completion::CompletionModel for GenericCompletionModel<Ext, H>
1905where
1906    crate::client::Client<Ext, H>:
1907        HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
1908    Ext: crate::client::Provider
1909        + OpenAICompatibleProvider
1910        + crate::client::DebugExt
1911        + Clone
1912        + WasmCompatSend
1913        + WasmCompatSync
1914        + 'static,
1915    H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
1916{
1917    type Response = Ext::Response;
1918    type StreamingResponse = StreamingCompletionResponse<Ext::StreamingUsage>;
1919
1920    type Client = crate::client::Client<Ext, H>;
1921
1922    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1923        Self::new(client.clone(), model)
1924    }
1925
1926    // OpenAI Chat Completions *defers* `response_format` while tools are present
1927    // and no tool result exists yet (see `should_apply_response_format`), then
1928    // applies it once a tool result is in the history. So the native constraint
1929    // does not suppress tool calls — they compose — which is what this flag
1930    // governs. (Caveat: a turn-1 answer with no tool call is therefore not
1931    // schema-constrained; `Native` is "guaranteed" only once tools have run.)
1932    // See issue #1928.
1933    fn composes_native_output_with_tools(&self) -> bool {
1934        // Providers that drop `output_schema` (SUPPORTS_RESPONSE_FORMAT =
1935        // false) cannot compose native structured output with tools; the
1936        // agent then falls back to tool-mode enforcement as their
1937        // pre-migration hand-rolled models did.
1938        Ext::SUPPORTS_RESPONSE_FORMAT
1939    }
1940
1941    async fn completion(
1942        &self,
1943        completion_request: CoreCompletionRequest,
1944    ) -> Result<completion::CompletionResponse<Ext::Response>, CompletionError> {
1945        let system_instructions = completion_request.preamble.clone();
1946        let record_telemetry_content = completion_request.record_telemetry_content;
1947        let options = CompletionModelOptions {
1948            strict_tools: self.strict_tools,
1949            tool_result_array_content: self.tool_result_array_content,
1950            prompt_caching: self.prompt_caching,
1951        };
1952        let mut request = self.client.ext().build_completion_request(
1953            self.model.to_owned(),
1954            completion_request,
1955            options,
1956        )?;
1957        self.client.ext().prepare_request(&mut request)?;
1958        let span = CompletionSpanBuilder::new(
1959            Ext::PROVIDER_NAME,
1960            &request.model,
1961            CompletionOperation::Chat,
1962        )
1963        .system_instructions(system_instructions.as_deref(), record_telemetry_content)
1964        .build();
1965
1966        let mut request_body = serde_json::to_value(&request)?;
1967        self.client
1968            .ext()
1969            .finalize_request_body_with_options(&mut request_body, options)?;
1970        if enabled!(Level::TRACE) {
1971            tracing::trace!(
1972                target: "rig::completions",
1973                "OpenAI Chat Completions completion request: {}",
1974                serde_json::to_string_pretty(&request_body)?
1975            );
1976        }
1977
1978        let body = serde_json::to_vec(&request_body)?;
1979        // Deliberately the configured model, not the per-request override:
1980        // Azure's deployment URL is pinned to the model handle.
1981        let path = self.client.ext().completion_path(&self.model);
1982
1983        let req = self
1984            .client
1985            .post(&path)?
1986            .body(body)
1987            .map_err(|e| CompletionError::HttpError(e.into()))?;
1988
1989        async move {
1990            let response = self.client.send(req).await?;
1991
1992            let status = response.status();
1993            if status.is_success() {
1994                let text = http_client::text(response).await?;
1995
1996                match serde_json::from_str::<ApiResponse<Ext::Response>>(&text)? {
1997                    ApiResponse::Ok(response) => {
1998                        let span = tracing::Span::current();
1999                        span.record_response_metadata(&response);
2000                        span.record_token_usage(&response.get_usage());
2001                        if enabled!(Level::TRACE) {
2002                            tracing::trace!(
2003                                target: "rig::completions",
2004                                "OpenAI Chat Completions completion response: {}",
2005                                serde_json::to_string_pretty(&response)?
2006                            );
2007                        }
2008
2009                        response.try_into()
2010                    }
2011                    ApiResponse::Err(err) => {
2012                        tracing::warn!(message = %err.message, "provider returned an error response");
2013                        Err(CompletionError::from_http_response(status, text))
2014                    }
2015                }
2016            } else {
2017                let text = http_client::text(response).await?;
2018                Err(CompletionError::from_http_response(status, text))
2019            }
2020        }
2021        .instrument(span)
2022        .await
2023    }
2024
2025    async fn stream(
2026        &self,
2027        request: CoreCompletionRequest,
2028    ) -> Result<
2029        crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
2030        CompletionError,
2031    > {
2032        GenericCompletionModel::stream(self, request).await
2033    }
2034}
2035
2036fn serialize_assistant_content_vec<S>(
2037    value: &Vec<AssistantContent>,
2038    serializer: S,
2039) -> Result<S::Ok, S::Error>
2040where
2041    S: Serializer,
2042{
2043    if value.is_empty() {
2044        serializer.serialize_str("")
2045    } else {
2046        value.serialize(serializer)
2047    }
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052    use super::*;
2053    use crate::completion::CompletionRequestBuilder;
2054    use crate::telemetry::ProviderResponseExt;
2055    use crate::test_utils::MockCompletionModel;
2056    use std::collections::HashMap;
2057
2058    fn test_document(id: &str, text: &str) -> crate::completion::Document {
2059        crate::completion::Document {
2060            id: id.to_string(),
2061            text: text.to_string(),
2062            additional_props: HashMap::new(),
2063        }
2064    }
2065
2066    fn request_with_multi_block_tool_result() -> CoreCompletionRequest {
2067        let tool_result = message::ToolResult {
2068            id: "result-id".to_string(),
2069            call_id: Some("call-id".to_string()),
2070            content: OneOrMany::many(vec![
2071                message::ToolResultContent::text("first"),
2072                message::ToolResultContent::text("second"),
2073            ])
2074            .expect("multiple tool-result blocks should be non-empty"),
2075        };
2076
2077        CoreCompletionRequest {
2078            model: None,
2079            preamble: None,
2080            chat_history: OneOrMany::one(message::Message::User {
2081                content: OneOrMany::one(message::UserContent::ToolResult(tool_result)),
2082            }),
2083            documents: vec![],
2084            tools: vec![],
2085            temperature: None,
2086            max_tokens: None,
2087            tool_choice: None,
2088            additional_params: None,
2089            output_schema: None,
2090            record_telemetry_content: false,
2091        }
2092    }
2093
2094    #[test]
2095    fn mixed_user_content_preserves_order_around_tool_results() {
2096        let content = OneOrMany::many(vec![
2097            message::UserContent::text("before"),
2098            message::UserContent::tool_result_with_call_id(
2099                "result-id",
2100                "call-id".to_string(),
2101                OneOrMany::one(message::ToolResultContent::text("tool output")),
2102            ),
2103            message::UserContent::text("after"),
2104        ])
2105        .expect("mixed content should be non-empty");
2106
2107        let messages = Vec::<Message>::try_from(content).expect("message conversion");
2108
2109        assert!(matches!(
2110            messages.as_slice(),
2111            [
2112                Message::User { content: before, .. },
2113                Message::ToolResult { tool_call_id, .. },
2114                Message::User { content: after, .. },
2115            ] if matches!(before.first(), UserContent::Text { text } if text == "before")
2116                && tool_call_id == "call-id"
2117                && matches!(after.first(), UserContent::Text { text } if text == "after")
2118        ));
2119    }
2120
2121    #[test]
2122    fn video_data_uri_with_unrecognized_mime_round_trips_as_url() {
2123        let original = "data:video/quicktime;base64,AAAA";
2124        let openai_content = UserContent::Video {
2125            video_url: VideoUrl {
2126                url: original.to_string(),
2127            },
2128        };
2129
2130        let rig_content: message::UserContent = openai_content.into();
2131        // Unrecognized MIME: kept as a URL source, not decomposed.
2132        assert!(matches!(
2133            &rig_content,
2134            message::UserContent::Video(video)
2135                if matches!(&video.data, message::DocumentSourceKind::Url(url) if url == original)
2136        ));
2137
2138        let back = UserContent::try_from(rig_content).expect("video should convert back");
2139        assert!(matches!(
2140            back,
2141            UserContent::Video { video_url } if video_url.url == original
2142        ));
2143    }
2144
2145    #[test]
2146    fn video_data_uri_with_known_mime_decomposes_to_base64() {
2147        let openai_content = UserContent::Video {
2148            video_url: VideoUrl {
2149                url: "data:video/mp4;base64,AAAA".to_string(),
2150            },
2151        };
2152
2153        let rig_content: message::UserContent = openai_content.into();
2154        assert!(matches!(
2155            &rig_content,
2156            message::UserContent::Video(video)
2157                if video.media_type == Some(crate::message::VideoMediaType::MP4)
2158                    && matches!(&video.data, message::DocumentSourceKind::Base64(data) if data == "AAAA")
2159        ));
2160    }
2161
2162    #[test]
2163    fn sanitize_plain_text_history_strips_tool_exchange_and_keeps_alternation() {
2164        let mut messages = vec![
2165            serde_json::json!({"role": "user", "content": "Look up the label."}),
2166            serde_json::json!({"role": "assistant", "tool_calls": [
2167                {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
2168            ]}),
2169            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "crimson"}),
2170            serde_json::json!({
2171                "role": "assistant",
2172                "content": [{"type": "text", "text": "The label is crimson."}],
2173                "reasoning_content": "thinking"
2174            }),
2175            serde_json::json!({"role": "user", "content": "Thanks!"}),
2176        ];
2177
2178        sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2179
2180        let roles = messages
2181            .iter()
2182            .map(|m| m["role"].as_str().unwrap_or_default())
2183            .collect::<Vec<_>>();
2184        // tool message removed, tool-call-only assistant dropped, no
2185        // consecutive assistants left.
2186        assert_eq!(roles, ["user", "assistant", "user"]);
2187        assert_eq!(messages[1]["content"], "The label is crimson.");
2188        assert!(messages[1].get("reasoning_content").is_none());
2189        assert!(messages[1].get("tool_calls").is_none());
2190    }
2191
2192    #[test]
2193    fn sanitize_plain_text_history_merges_consecutive_user_messages() {
2194        // Dropping a tool exchange whose final assistant answer never made it
2195        // into history leaves user/user adjacency, which alternation-strict
2196        // APIs reject.
2197        let mut messages = vec![
2198            serde_json::json!({"role": "user", "content": "Look it up."}),
2199            serde_json::json!({"role": "assistant", "tool_calls": [
2200                {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
2201            ]}),
2202            serde_json::json!({"role": "tool", "tool_call_id": "call_1", "content": "crimson"}),
2203            serde_json::json!({"role": "user", "content": "Ask again."}),
2204        ];
2205
2206        sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2207
2208        assert_eq!(messages.len(), 1);
2209        assert_eq!(messages[0]["role"], "user");
2210        assert_eq!(messages[0]["content"], "Look it up.\nAsk again.");
2211    }
2212
2213    #[test]
2214    fn flatten_text_content_parts_treats_refusals_as_text() {
2215        let mut content = serde_json::json!([
2216            {"type": "text", "text": "Partly:"},
2217            {"type": "refusal", "refusal": "I cannot help with that."}
2218        ]);
2219
2220        flatten_text_content_parts(&mut content, "\n", true);
2221
2222        assert_eq!(content, "Partly:\nI cannot help with that.");
2223    }
2224
2225    #[test]
2226    fn sanitize_plain_text_history_merges_consecutive_assistant_messages() {
2227        let mut messages = vec![
2228            serde_json::json!({"role": "assistant", "content": "First."}),
2229            serde_json::json!({"role": "tool", "tool_call_id": "c", "content": "x"}),
2230            serde_json::json!({"role": "assistant", "content": "Second."}),
2231        ];
2232
2233        sanitize_plain_text_history(&mut messages, Some(("\n", true)), false, true);
2234
2235        assert_eq!(messages.len(), 1);
2236        assert_eq!(messages[0]["content"], "First.\nSecond.");
2237    }
2238
2239    #[test]
2240    fn tool_result_array_content_preserves_multiple_text_blocks() {
2241        let request = CompletionRequest::try_from(OpenAIRequestParams {
2242            model: "gpt-4o-mini".to_string(),
2243            request: request_with_multi_block_tool_result(),
2244            strict_tools: false,
2245            tool_result_array_content: true,
2246            supports_response_format: true,
2247            supports_tools: true,
2248        })
2249        .expect("request conversion should succeed");
2250
2251        let wire = serde_json::to_value(&request.messages).expect("messages should serialize");
2252
2253        assert_eq!(
2254            wire,
2255            serde_json::json!([
2256                {
2257                    "role": "tool",
2258                    "tool_call_id": "call-id",
2259                    "content": [
2260                        {
2261                            "type": "text",
2262                            "text": "first"
2263                        },
2264                        {
2265                            "type": "text",
2266                            "text": "second"
2267                        }
2268                    ]
2269                }
2270            ])
2271        );
2272    }
2273
2274    #[test]
2275    fn tool_result_string_content_flattens_multiple_text_blocks() {
2276        let request = CompletionRequest::try_from(OpenAIRequestParams {
2277            model: "gpt-4o-mini".to_string(),
2278            request: request_with_multi_block_tool_result(),
2279            strict_tools: false,
2280            tool_result_array_content: false,
2281            supports_response_format: true,
2282            supports_tools: true,
2283        })
2284        .expect("request conversion should succeed");
2285
2286        let wire = serde_json::to_value(&request.messages).expect("messages should serialize");
2287
2288        assert_eq!(
2289            wire,
2290            serde_json::json!([
2291                {
2292                    "role": "tool",
2293                    "tool_call_id": "call-id",
2294                    "content": "first\nsecond"
2295                }
2296            ])
2297        );
2298    }
2299
2300    #[test]
2301    fn multiple_tool_result_blocks_convert_to_distinct_content_parts() {
2302        let result = message::ToolResult {
2303            id: "result-id".to_string(),
2304            call_id: Some("call-id".to_string()),
2305            content: OneOrMany::many(vec![
2306                message::ToolResultContent::text("first"),
2307                message::ToolResultContent::json(serde_json::json!({
2308                    "status": "ok"
2309                })),
2310                message::ToolResultContent::text("second"),
2311            ])
2312            .expect("tool-result content should be non-empty"),
2313        };
2314
2315        let converted = Message::try_from(result).expect("tool result should convert");
2316
2317        assert_eq!(
2318            converted,
2319            Message::ToolResult {
2320                tool_call_id: "call-id".to_string(),
2321                content: ToolResultContentValue::Array(vec![
2322                    ToolResultContent::from("first".to_string()),
2323                    ToolResultContent::from(r#"{"status":"ok"}"#.to_string()),
2324                    ToolResultContent::from("second".to_string()),
2325                ]),
2326            }
2327        );
2328    }
2329
2330    #[test]
2331    fn test_openai_request_uses_request_model_override() {
2332        let request = crate::completion::CompletionRequest {
2333            model: Some("gpt-4.1".to_string()),
2334            preamble: None,
2335            chat_history: crate::OneOrMany::one("Hello".into()),
2336            documents: vec![],
2337            tools: vec![],
2338            temperature: None,
2339            max_tokens: None,
2340            tool_choice: None,
2341            additional_params: None,
2342            output_schema: None,
2343            record_telemetry_content: false,
2344        };
2345
2346        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2347            model: "gpt-4o-mini".to_string(),
2348            request,
2349            strict_tools: false,
2350            tool_result_array_content: false,
2351            supports_response_format: true,
2352            supports_tools: true,
2353        })
2354        .expect("request conversion should succeed");
2355        let serialized =
2356            serde_json::to_value(openai_request).expect("serialization should succeed");
2357
2358        assert_eq!(serialized["model"], "gpt-4.1");
2359    }
2360
2361    #[test]
2362    fn test_openai_request_uses_default_model_when_override_unset() {
2363        let request = crate::completion::CompletionRequest {
2364            model: None,
2365            preamble: None,
2366            chat_history: crate::OneOrMany::one("Hello".into()),
2367            documents: vec![],
2368            tools: vec![],
2369            temperature: None,
2370            max_tokens: None,
2371            tool_choice: None,
2372            additional_params: None,
2373            output_schema: None,
2374            record_telemetry_content: false,
2375        };
2376
2377        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2378            model: "gpt-4o-mini".to_string(),
2379            request,
2380            strict_tools: false,
2381            tool_result_array_content: false,
2382            supports_response_format: true,
2383            supports_tools: true,
2384        })
2385        .expect("request conversion should succeed");
2386        let serialized =
2387            serde_json::to_value(openai_request).expect("serialization should succeed");
2388
2389        assert_eq!(serialized["model"], "gpt-4o-mini");
2390    }
2391
2392    #[test]
2393    fn openai_chat_request_keeps_documents_after_system_messages() {
2394        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
2395            .message(crate::completion::Message::system("System prompt"))
2396            .message(crate::completion::Message::user("Earlier user turn"))
2397            .message(crate::completion::Message::assistant(
2398                "Earlier assistant turn",
2399            ))
2400            .document(test_document("doc1", "Document text."))
2401            .build();
2402
2403        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2404            model: "gpt-4o-mini".to_string(),
2405            request,
2406            strict_tools: false,
2407            tool_result_array_content: false,
2408            supports_response_format: true,
2409            supports_tools: true,
2410        })
2411        .expect("request conversion should succeed");
2412
2413        let serialized =
2414            serde_json::to_value(&openai_request.messages).expect("messages should serialize");
2415        let messages = serialized.as_array().expect("messages should be an array");
2416
2417        assert_eq!(messages.len(), 5);
2418        assert_eq!(messages[0]["role"], "system");
2419        assert_eq!(messages[1]["role"], "user");
2420        assert!(
2421            messages[1].to_string().contains("<file id: doc1>"),
2422            "document message should follow system message: {messages:?}"
2423        );
2424        assert_eq!(messages[2]["role"], "user");
2425        assert!(
2426            messages[2].to_string().contains("Earlier user turn"),
2427            "prior user history should follow document message: {messages:?}"
2428        );
2429        assert_eq!(messages[3]["role"], "assistant");
2430        assert!(
2431            messages[3].to_string().contains("Earlier assistant turn"),
2432            "prior assistant history should follow prior user history: {messages:?}"
2433        );
2434        assert_eq!(messages[4]["role"], "user");
2435        assert!(
2436            messages[4].to_string().contains("Prompt"),
2437            "prompt should remain last: {messages:?}"
2438        );
2439    }
2440
2441    #[test]
2442    fn openai_chat_direct_request_keeps_documents_after_system_messages() {
2443        let request = CoreCompletionRequest {
2444            model: None,
2445            preamble: None,
2446            chat_history: crate::OneOrMany::many(vec![
2447                crate::completion::Message::system("System prompt"),
2448                crate::completion::Message::assistant("Earlier assistant turn"),
2449                crate::completion::Message::system("Mid-conversation instruction"),
2450                crate::completion::Message::user("Prompt"),
2451            ])
2452            .unwrap(),
2453            documents: vec![test_document("doc1", "Document text.")],
2454            tools: vec![],
2455            temperature: None,
2456            max_tokens: None,
2457            tool_choice: None,
2458            additional_params: None,
2459            output_schema: None,
2460            record_telemetry_content: false,
2461        };
2462
2463        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2464            model: "gpt-4o-mini".to_string(),
2465            request,
2466            strict_tools: false,
2467            tool_result_array_content: false,
2468            supports_response_format: true,
2469            supports_tools: true,
2470        })
2471        .expect("request conversion should succeed");
2472
2473        let serialized =
2474            serde_json::to_value(&openai_request.messages).expect("messages should serialize");
2475        let messages = serialized.as_array().expect("messages should be an array");
2476
2477        assert_eq!(messages.len(), 5);
2478        assert_eq!(messages[0]["role"], "system");
2479        assert_eq!(messages[1]["role"], "user");
2480        assert!(
2481            messages[1].to_string().contains("<file id: doc1>"),
2482            "document message should follow leading system messages: {messages:?}"
2483        );
2484        assert_eq!(messages[2]["role"], "assistant");
2485        assert_eq!(messages[3]["role"], "system");
2486        assert_eq!(messages[4]["role"], "user");
2487        assert_eq!(
2488            messages
2489                .iter()
2490                .filter(|message| message.to_string().contains("<file id: doc1>"))
2491                .count(),
2492            1,
2493            "document message should appear exactly once: {messages:?}"
2494        );
2495    }
2496
2497    #[test]
2498    fn assistant_reasoning_alone_is_dropped() {
2499        let assistant_content = OneOrMany::one(message::AssistantContent::reasoning("hidden"));
2500
2501        let converted: Vec<Message> = assistant_content
2502            .try_into()
2503            .expect("conversion should work");
2504
2505        assert!(converted.is_empty());
2506    }
2507
2508    // Regression test: providers that serve thinking models over the OpenAI
2509    // Chat Completions schema (DeepSeek-R1, GLM-4.6, Qwen3-Thinking) return
2510    // 400 "thinking is enabled but reasoning_content is missing" on the next
2511    // turn if the prior assistant tool-call message didn't echo the reasoning.
2512    #[test]
2513    fn assistant_reasoning_is_attached_to_tool_call_message() {
2514        let assistant_content = OneOrMany::many(vec![
2515            message::AssistantContent::reasoning("hidden"),
2516            message::AssistantContent::text("visible"),
2517            message::AssistantContent::tool_call(
2518                "call_1",
2519                "subtract",
2520                serde_json::json!({"x": 2, "y": 1}),
2521            ),
2522        ])
2523        .expect("non-empty assistant content");
2524
2525        let converted: Vec<Message> = assistant_content
2526            .try_into()
2527            .expect("conversion should work");
2528        assert_eq!(converted.len(), 1);
2529
2530        match &converted[0] {
2531            Message::Assistant {
2532                content,
2533                tool_calls,
2534                reasoning,
2535                ..
2536            } => {
2537                assert_eq!(
2538                    content,
2539                    &vec![AssistantContent::Text {
2540                        text: "visible".to_string()
2541                    }]
2542                );
2543                assert_eq!(tool_calls.len(), 1);
2544                assert_eq!(tool_calls[0].id, "call_1");
2545                assert_eq!(tool_calls[0].function.name, "subtract");
2546                assert_eq!(
2547                    tool_calls[0].function.arguments,
2548                    serde_json::json!({"x": 2, "y": 1})
2549                );
2550                assert_eq!(reasoning.as_deref(), Some("hidden"));
2551            }
2552            _ => panic!("expected assistant message"),
2553        }
2554
2555        let json = serde_json::to_value(&converted[0]).expect("serialize");
2556        assert_eq!(json["reasoning_content"], "hidden");
2557    }
2558
2559    #[test]
2560    fn assistant_reasoning_roundtrips_back_to_rig_message() {
2561        let assistant = Message::Assistant {
2562            content: vec![AssistantContent::Text {
2563                text: "visible".to_string(),
2564            }],
2565            reasoning: Some("hidden".to_string()),
2566            refusal: None,
2567            audio: None,
2568            name: None,
2569            tool_calls: vec![],
2570            reasoning_details: vec![],
2571            images: vec![],
2572        };
2573
2574        let rig_msg: message::Message = assistant.try_into().expect("convert back");
2575
2576        let message::Message::Assistant { content, .. } = rig_msg else {
2577            panic!("expected assistant");
2578        };
2579
2580        let items: Vec<_> = content.into_iter().collect();
2581        assert_eq!(items.len(), 2);
2582        assert!(matches!(items[0], message::AssistantContent::Reasoning(_)));
2583        assert!(matches!(items[1], message::AssistantContent::Text(_)));
2584    }
2585
2586    #[test]
2587    fn provider_response_text_response_reads_assistant_multipart_output() {
2588        let response = CompletionResponse {
2589            id: "resp_123".to_owned(),
2590            object: "chat.completion".to_owned(),
2591            created: 0,
2592            model: GPT_4O.to_owned(),
2593            system_fingerprint: None,
2594            choices: vec![Choice {
2595                index: 0,
2596                message: Message::Assistant {
2597                    content: vec![
2598                        AssistantContent::Text {
2599                            text: "first".to_owned(),
2600                        },
2601                        AssistantContent::Refusal {
2602                            refusal: "second".to_owned(),
2603                        },
2604                        AssistantContent::Text {
2605                            text: "third".to_owned(),
2606                        },
2607                    ],
2608                    reasoning: Some("hidden".to_owned()),
2609                    refusal: None,
2610                    audio: None,
2611                    name: None,
2612                    tool_calls: vec![],
2613                    reasoning_details: vec![],
2614                    images: vec![],
2615                },
2616                logprobs: None,
2617                finish_reason: "stop".to_owned(),
2618            }],
2619            usage: None,
2620        };
2621
2622        assert_eq!(
2623            response.get_text_response(),
2624            Some("first\nsecond\nthird".to_owned())
2625        );
2626    }
2627
2628    #[test]
2629    fn provider_response_text_response_falls_back_to_assistant_refusal_field() {
2630        let response = CompletionResponse {
2631            id: "resp_123".to_owned(),
2632            object: "chat.completion".to_owned(),
2633            created: 0,
2634            model: GPT_4O.to_owned(),
2635            system_fingerprint: None,
2636            choices: vec![Choice {
2637                index: 0,
2638                message: Message::Assistant {
2639                    content: vec![],
2640                    reasoning: None,
2641                    refusal: Some("blocked".to_owned()),
2642                    audio: None,
2643                    name: None,
2644                    tool_calls: vec![],
2645                    reasoning_details: vec![],
2646                    images: vec![],
2647                },
2648                logprobs: None,
2649                finish_reason: "stop".to_owned(),
2650            }],
2651            usage: None,
2652        };
2653
2654        assert_eq!(response.get_text_response(), Some("blocked".to_owned()));
2655    }
2656
2657    #[test]
2658    fn test_max_tokens_is_forwarded_to_request() {
2659        let request = crate::completion::CompletionRequest {
2660            model: None,
2661            preamble: None,
2662            chat_history: crate::OneOrMany::one("Hello".into()),
2663            documents: vec![],
2664            tools: vec![],
2665            temperature: None,
2666            max_tokens: Some(4096),
2667            tool_choice: None,
2668            additional_params: None,
2669            output_schema: None,
2670            record_telemetry_content: false,
2671        };
2672
2673        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2674            model: "gpt-4o-mini".to_string(),
2675            request,
2676            strict_tools: false,
2677            tool_result_array_content: false,
2678            supports_response_format: true,
2679            supports_tools: true,
2680        })
2681        .expect("request conversion should succeed");
2682        let serialized =
2683            serde_json::to_value(openai_request).expect("serialization should succeed");
2684
2685        assert_eq!(serialized["max_tokens"], 4096);
2686    }
2687
2688    #[test]
2689    fn test_max_tokens_omitted_when_none() {
2690        let request = crate::completion::CompletionRequest {
2691            model: None,
2692            preamble: None,
2693            chat_history: crate::OneOrMany::one("Hello".into()),
2694            documents: vec![],
2695            tools: vec![],
2696            temperature: None,
2697            max_tokens: None,
2698            tool_choice: None,
2699            additional_params: None,
2700            output_schema: None,
2701            record_telemetry_content: false,
2702        };
2703
2704        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2705            model: "gpt-4o-mini".to_string(),
2706            request,
2707            strict_tools: false,
2708            tool_result_array_content: false,
2709            supports_response_format: true,
2710            supports_tools: true,
2711        })
2712        .expect("request conversion should succeed");
2713        let serialized =
2714            serde_json::to_value(openai_request).expect("serialization should succeed");
2715
2716        assert!(serialized.get("max_tokens").is_none());
2717    }
2718
2719    #[test]
2720    fn request_conversion_errors_when_all_messages_are_filtered() {
2721        let request = CoreCompletionRequest {
2722            model: None,
2723            preamble: None,
2724            chat_history: OneOrMany::one(message::Message::Assistant {
2725                id: None,
2726                content: OneOrMany::one(message::AssistantContent::reasoning("hidden")),
2727            }),
2728            documents: vec![],
2729            tools: vec![],
2730            temperature: None,
2731            max_tokens: None,
2732            tool_choice: None,
2733            additional_params: None,
2734            output_schema: None,
2735            record_telemetry_content: false,
2736        };
2737
2738        let result = CompletionRequest::try_from(OpenAIRequestParams {
2739            model: "gpt-4o-mini".to_string(),
2740            request,
2741            strict_tools: false,
2742            tool_result_array_content: false,
2743            supports_response_format: true,
2744            supports_tools: true,
2745        });
2746
2747        assert!(matches!(result, Err(CompletionError::RequestError(_))));
2748    }
2749
2750    #[test]
2751    fn request_conversion_omits_response_format_on_initial_tool_turn() {
2752        let request = CoreCompletionRequest {
2753            model: None,
2754            preamble: None,
2755            chat_history: OneOrMany::one(message::Message::user(
2756                "Hello, whats the weather in London?",
2757            )),
2758            documents: vec![],
2759            tools: vec![completion::ToolDefinition {
2760                name: "weather".to_string(),
2761                description: "Get the weather".to_string(),
2762                parameters: serde_json::json!({
2763                    "type": "object",
2764                    "properties": {
2765                        "city": { "type": "string" }
2766                    },
2767                    "required": ["city"]
2768                }),
2769            }],
2770            temperature: None,
2771            max_tokens: None,
2772            tool_choice: None,
2773            additional_params: None,
2774            output_schema: Some(
2775                serde_json::from_value(serde_json::json!({
2776                    "title": "WeatherResponse",
2777                    "type": "object",
2778                    "properties": {
2779                        "city": { "type": "string" },
2780                        "weather": { "type": "string" }
2781                    },
2782                    "required": ["city", "weather"]
2783                }))
2784                .expect("schema should deserialize"),
2785            ),
2786            record_telemetry_content: false,
2787        };
2788
2789        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2790            model: "gpt-4o-mini".to_string(),
2791            request,
2792            strict_tools: false,
2793            tool_result_array_content: false,
2794            supports_response_format: true,
2795            supports_tools: true,
2796        })
2797        .expect("request conversion should succeed");
2798
2799        let serialized =
2800            serde_json::to_value(openai_request).expect("serialization should succeed");
2801
2802        assert!(
2803            serialized.get("response_format").is_none(),
2804            "initial tool turn should omit response_format: {serialized:?}"
2805        );
2806    }
2807
2808    #[test]
2809    fn request_conversion_restores_response_format_after_tool_result() {
2810        let request = CoreCompletionRequest {
2811            model: None,
2812            preamble: None,
2813            chat_history: OneOrMany::many(vec![
2814                message::Message::user("Hello, whats the weather in London?"),
2815                message::Message::Assistant {
2816                    id: None,
2817                    content: OneOrMany::one(message::AssistantContent::tool_call(
2818                        "call_1",
2819                        "weather",
2820                        serde_json::json!({ "city": "London" }),
2821                    )),
2822                },
2823                message::Message::tool_result(
2824                    "call_1",
2825                    "The weather in London is all fire and brimstone",
2826                ),
2827            ])
2828            .expect("history should be non-empty"),
2829            documents: vec![],
2830            tools: vec![completion::ToolDefinition {
2831                name: "weather".to_string(),
2832                description: "Get the weather".to_string(),
2833                parameters: serde_json::json!({
2834                    "type": "object",
2835                    "properties": {
2836                        "city": { "type": "string" }
2837                    },
2838                    "required": ["city"]
2839                }),
2840            }],
2841            temperature: None,
2842            max_tokens: None,
2843            tool_choice: None,
2844            additional_params: None,
2845            output_schema: Some(
2846                serde_json::from_value(serde_json::json!({
2847                    "title": "WeatherResponse",
2848                    "type": "object",
2849                    "properties": {
2850                        "city": { "type": "string" },
2851                        "weather": { "type": "string" }
2852                    },
2853                    "required": ["city", "weather"]
2854                }))
2855                .expect("schema should deserialize"),
2856            ),
2857            record_telemetry_content: false,
2858        };
2859
2860        let openai_request = CompletionRequest::try_from(OpenAIRequestParams {
2861            model: "gpt-4o-mini".to_string(),
2862            request,
2863            strict_tools: false,
2864            tool_result_array_content: false,
2865            supports_response_format: true,
2866            supports_tools: true,
2867        })
2868        .expect("request conversion should succeed");
2869
2870        let serialized =
2871            serde_json::to_value(openai_request).expect("serialization should succeed");
2872
2873        assert!(
2874            serialized.get("response_format").is_some(),
2875            "follow-up turn should restore response_format: {serialized:?}"
2876        );
2877    }
2878
2879    #[test]
2880    fn deserialize_llama_cpp_tool_call() {
2881        let request = r#"{
2882            "choices": [{
2883                "finish_reason": "tool_calls",
2884                "index": 0,
2885                "message": {
2886                    "role": "assistant",
2887                    "content": "",
2888                    "tool_calls": [{ "type": "function", "function": { "name": "hello_world", "arguments": { "city": "Paris" } }, "id": "xxx" }]
2889                }
2890            }],
2891            "created": 0,
2892            "model": "gpt-4o-mini",
2893            "system_fingerprint": "fp_xxx",
2894            "object": "chat.completion",
2895            "usage": { "completion_tokens": 13, "prompt_tokens": 255, "total_tokens": 268 },
2896            "id": "xxx"
2897        }
2898        "#;
2899        let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
2900
2901        let ApiResponse::Ok(response) = response else {
2902            panic!("expected successful completion response");
2903        };
2904        assert_eq!(response.choices.len(), 1);
2905
2906        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
2907            panic!("expected assistant message");
2908        };
2909        assert_eq!(tool_calls.len(), 1);
2910        assert_eq!(tool_calls[0].id, "xxx");
2911        assert_eq!(tool_calls[0].function.name, "hello_world");
2912        assert_eq!(
2913            tool_calls[0].function.arguments,
2914            serde_json::json!({"city": "Paris"})
2915        );
2916    }
2917
2918    #[test]
2919    fn deserialize_openai_stringified_tool_call() {
2920        let request = r#"{
2921            "choices": [{
2922                "finish_reason": "tool_calls",
2923                "index": 0,
2924                "message": {
2925                    "role": "assistant",
2926                    "content": "",
2927                    "tool_calls": [{ "type": "function", "function": { "name": "hello_world", "arguments": "{\"city\":\"Paris\"}" }, "id": "xxx" }]
2928                }
2929            }],
2930            "created": 0,
2931            "model": "gpt-4o-mini",
2932            "system_fingerprint": "fp_xxx",
2933            "object": "chat.completion",
2934            "usage": { "completion_tokens": 13, "prompt_tokens": 255, "total_tokens": 268 },
2935            "id": "xxx"
2936        }
2937        "#;
2938        let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
2939
2940        let ApiResponse::Ok(response) = response else {
2941            panic!("expected successful completion response");
2942        };
2943        assert_eq!(response.choices.len(), 1);
2944
2945        let Message::Assistant { tool_calls, .. } = &response.choices[0].message else {
2946            panic!("expected assistant message");
2947        };
2948        assert_eq!(tool_calls.len(), 1);
2949        assert_eq!(tool_calls[0].id, "xxx");
2950        assert_eq!(tool_calls[0].function.name, "hello_world");
2951        assert_eq!(
2952            tool_calls[0].function.arguments,
2953            serde_json::json!({"city": "Paris"})
2954        );
2955    }
2956
2957    #[test]
2958    fn deserialize_llama_cpp_response_with_reasoning_content() {
2959        let request = r#"
2960        {
2961            "choices": [
2962                {
2963                    "finish_reason": "stop",
2964                    "index": 0,
2965                    "message": {
2966                        "role": "assistant",
2967                        "content": "",
2968                        "reasoning_content": "Now I understand the structure better. I need to: ..."
2969                    }
2970                }
2971            ],
2972            "created": 1776750378,
2973            "model": "unsloth/Qwen3.6-35B-A3B-GGUF:Q8_0",
2974            "system_fingerprint": "fp_xxx",
2975            "object": "chat.completion",
2976            "usage": {
2977                "completion_tokens": 920,
2978                "prompt_tokens": 27806,
2979                "total_tokens": 28726,
2980                "prompt_tokens_details": { "cached_tokens": 18698 }
2981            },
2982            "id": "chatcmpl-xxxx",
2983            "timings": {
2984                "cache_n": 18698,
2985                "prompt_n": 9108,
2986                "prompt_ms": 226645.81,
2987                "prompt_per_token_ms": 24.884256697408873,
2988                "prompt_per_second": 40.186050648807495,
2989                "predicted_n": 920,
2990                "predicted_ms": 177167.955,
2991                "predicted_per_token_ms": 192.57386413043477,
2992                "predicted_per_second": 5.192812661860888
2993            }
2994        }
2995        "#;
2996        let response = serde_json::from_str::<ApiResponse<CompletionResponse>>(request).unwrap();
2997        let ApiResponse::Ok(response) = response else {
2998            panic!("expected successful completion response");
2999        };
3000
3001        let response: completion::CompletionResponse<CompletionResponse> =
3002            response.try_into().unwrap();
3003
3004        assert_eq!(response.choice.len(), 1);
3005
3006        let completion::message::AssistantContent::Reasoning(reasoning) = response.choice.first()
3007        else {
3008            panic!("expected assistant content to be reasoning");
3009        };
3010        assert_eq!(
3011            reasoning.first_text(),
3012            Some("Now I understand the structure better. I need to: ...")
3013        );
3014    }
3015
3016    #[test]
3017    fn pdf_base64_document_serializes_as_file_content_part() {
3018        let doc = message::UserContent::Document(message::Document {
3019            data: DocumentSourceKind::Base64("JVBERi0xLjQK".into()),
3020            media_type: Some(message::DocumentMediaType::PDF),
3021            additional_params: None,
3022        });
3023        let converted: UserContent = doc.try_into().expect("conversion should succeed");
3024        let json = serde_json::to_value(&converted).expect("serialize");
3025
3026        assert_eq!(json["type"], "file");
3027        assert_eq!(
3028            json["file"]["file_data"],
3029            "data:application/pdf;base64,JVBERi0xLjQK"
3030        );
3031        assert_eq!(json["file"]["filename"], "document.pdf");
3032        assert!(json["file"].get("file_id").is_none());
3033    }
3034
3035    #[test]
3036    fn file_id_document_serializes_as_file_content_part() {
3037        let doc = message::UserContent::Document(message::Document {
3038            data: DocumentSourceKind::FileId("file_abc".into()),
3039            media_type: None,
3040            additional_params: None,
3041        });
3042        let converted: UserContent = doc.try_into().expect("conversion should succeed");
3043        let json = serde_json::to_value(&converted).expect("serialize");
3044
3045        assert_eq!(json["type"], "file");
3046        assert_eq!(json["file"]["file_id"], "file_abc");
3047        assert!(json["file"].get("file_data").is_none());
3048    }
3049
3050    #[test]
3051    fn base64_image_without_detail_defaults_to_auto() {
3052        let image = message::UserContent::Image(message::Image {
3053            data: DocumentSourceKind::Base64("iVBORw0KGgo=".into()),
3054            media_type: Some(message::ImageMediaType::PNG),
3055            detail: None,
3056            additional_params: None,
3057        });
3058        let converted: UserContent = image.try_into().expect("conversion should succeed");
3059        let UserContent::Image { image_url } = converted else {
3060            panic!("expected image content");
3061        };
3062
3063        assert_eq!(image_url.url, "data:image/png;base64,iVBORw0KGgo=");
3064        assert_eq!(image_url.detail, Some(ImageDetail::Auto));
3065    }
3066
3067    // Regression guard: callers passing markdown/plain text wrapped in
3068    // `UserContent::Document` should keep getting flattened to `text`.
3069    #[test]
3070    fn non_pdf_document_still_serializes_as_text() {
3071        let doc = message::UserContent::Document(message::Document {
3072            data: DocumentSourceKind::String("# Markdown".into()),
3073            media_type: None,
3074            additional_params: None,
3075        });
3076        let converted: UserContent = doc.try_into().expect("conversion should succeed");
3077        let json = serde_json::to_value(&converted).expect("serialize");
3078
3079        assert_eq!(json["type"], "text");
3080        assert_eq!(json["text"], "# Markdown");
3081    }
3082
3083    #[test]
3084    fn pdf_url_document_returns_conversion_error() {
3085        let doc = message::UserContent::Document(message::Document {
3086            data: DocumentSourceKind::Url("https://example.com/x.pdf".into()),
3087            media_type: Some(message::DocumentMediaType::PDF),
3088            additional_params: None,
3089        });
3090        let res: Result<UserContent, _> = doc.try_into();
3091        assert!(matches!(
3092            res,
3093            Err(message::MessageError::ConversionError(_))
3094        ));
3095    }
3096
3097    #[test]
3098    fn pdf_raw_document_returns_conversion_error() {
3099        let doc = message::UserContent::Document(message::Document {
3100            data: DocumentSourceKind::Raw(b"%PDF-1.4\n".to_vec()),
3101            media_type: Some(message::DocumentMediaType::PDF),
3102            additional_params: None,
3103        });
3104        let res: Result<UserContent, _> = doc.try_into();
3105        assert!(matches!(
3106            res,
3107            Err(message::MessageError::ConversionError(_))
3108        ));
3109    }
3110
3111    #[test]
3112    fn file_user_content_deserializes_from_wire_json() {
3113        let raw = r#"{"type":"file","file":{"file_data":"data:application/pdf;base64,AAAA","filename":"x.pdf"}}"#;
3114        let parsed: UserContent = serde_json::from_str(raw).expect("deserialize");
3115        let UserContent::File { file } = parsed else {
3116            panic!("expected File variant");
3117        };
3118        assert_eq!(
3119            file.file_data.as_deref(),
3120            Some("data:application/pdf;base64,AAAA")
3121        );
3122        assert_eq!(file.filename.as_deref(), Some("x.pdf"));
3123        assert!(file.file_id.is_none());
3124    }
3125
3126    #[test]
3127    fn file_variant_round_trips_back_to_pdf_document() {
3128        let wire = UserContent::File {
3129            file: FileData {
3130                file_data: Some("data:application/pdf;base64,QUJD".to_string()),
3131                file_id: None,
3132                filename: Some("document.pdf".to_string()),
3133            },
3134        };
3135        let rig: message::UserContent = wire.into();
3136        let message::UserContent::Document(doc) = rig else {
3137            panic!("expected Document");
3138        };
3139        assert_eq!(doc.media_type, Some(message::DocumentMediaType::PDF));
3140        assert!(matches!(doc.data, DocumentSourceKind::Base64(ref b) if b == "QUJD"));
3141    }
3142
3143    #[test]
3144    fn file_variant_with_file_id_only_round_trips_to_document_file_id() {
3145        let wire = UserContent::File {
3146            file: FileData {
3147                file_data: None,
3148                file_id: Some("file_abc".to_string()),
3149                filename: None,
3150            },
3151        };
3152        let rig: message::UserContent = wire.into();
3153        let message::UserContent::Document(doc) = rig else {
3154            panic!("expected Document");
3155        };
3156        assert_eq!(doc.media_type, None);
3157        assert!(matches!(doc.data, DocumentSourceKind::FileId(ref id) if id == "file_abc"));
3158
3159        let converted: UserContent = message::UserContent::Document(doc)
3160            .try_into()
3161            .expect("conversion should succeed");
3162        let json = serde_json::to_value(&converted).expect("serialize");
3163
3164        assert_eq!(json["type"], "file");
3165        assert_eq!(json["file"]["file_id"], "file_abc");
3166        assert!(json["file"].get("file_data").is_none());
3167    }
3168
3169    // Guards against `OneOrMany::many` flattening at the User content site:
3170    // a mixed text + PDF message must produce one User message with both parts.
3171    #[test]
3172    fn mixed_text_and_pdf_user_message_produces_two_content_parts() {
3173        let user = message::Message::User {
3174            content: OneOrMany::many(vec![
3175                message::UserContent::text("What is in this PDF?"),
3176                message::UserContent::Document(message::Document {
3177                    data: DocumentSourceKind::Base64("JVBERi0K".into()),
3178                    media_type: Some(message::DocumentMediaType::PDF),
3179                    additional_params: None,
3180                }),
3181            ])
3182            .expect("non-empty content"),
3183        };
3184        let converted: Vec<Message> = user.try_into().expect("conversion should succeed");
3185        assert_eq!(converted.len(), 1);
3186        let Message::User { content, .. } = &converted[0] else {
3187            panic!("expected user message");
3188        };
3189        let parts: Vec<&UserContent> = content.iter().collect();
3190        assert_eq!(parts.len(), 2);
3191        assert!(matches!(parts[0], UserContent::Text { .. }));
3192        assert!(matches!(parts[1], UserContent::File { .. }));
3193    }
3194
3195    #[tokio::test]
3196    async fn completion_preserves_raw_provider_error_json_on_api_error_envelope() {
3197        use crate::client::CompletionClient;
3198        use crate::completion::CompletionModel;
3199        use crate::providers::openai::CompletionsClient;
3200        use crate::test_utils::RecordingHttpClient;
3201
3202        let body = r#"{"message":"slow down","type":"rate_limit","code":"rate_limit_exceeded"}"#;
3203        let http_client =
3204            RecordingHttpClient::with_error_response(http::StatusCode::ACCEPTED, body);
3205        let client = CompletionsClient::builder()
3206            .api_key("test-key")
3207            .http_client(http_client)
3208            .build()
3209            .expect("build client");
3210        let model = client.completion_model("gpt-4o-mini");
3211        let request = model.completion_request("hello").build();
3212
3213        let error = model
3214            .completion(request)
3215            .await
3216            .expect_err("completion should fail with provider error envelope");
3217
3218        match &error {
3219            CompletionError::ProviderResponse(stored) => {
3220                assert_eq!(stored.body, body);
3221                assert_eq!(stored.status, Some(http::StatusCode::ACCEPTED));
3222                assert_eq!(error.provider_response_body(), Some(body));
3223                assert_eq!(
3224                    error.provider_response_status(),
3225                    Some(http::StatusCode::ACCEPTED)
3226                );
3227                let json = error
3228                    .provider_response_json()
3229                    .expect("raw body should be valid JSON")
3230                    .expect("parsed JSON should be present");
3231                assert_eq!(json["code"], "rate_limit_exceeded");
3232                assert_eq!(json["type"], "rate_limit");
3233            }
3234            other => panic!("expected ProviderResponse, got {other:?}"),
3235        }
3236    }
3237
3238    #[tokio::test]
3239    async fn completion_http_non_success_preserves_status_and_body() {
3240        use crate::client::CompletionClient;
3241        use crate::completion::CompletionModel;
3242        use crate::providers::openai::CompletionsClient;
3243        use crate::test_utils::RecordingHttpClient;
3244
3245        let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error"}}"#;
3246        let http_client =
3247            RecordingHttpClient::with_error_response(http::StatusCode::TOO_MANY_REQUESTS, body);
3248        let client = CompletionsClient::builder()
3249            .api_key("test-key")
3250            .http_client(http_client)
3251            .build()
3252            .expect("build client");
3253        let model = client.completion_model("gpt-4o-mini");
3254        let request = model.completion_request("hello").build();
3255
3256        let error = model
3257            .completion(request)
3258            .await
3259            .expect_err("completion should fail with non-success status");
3260
3261        assert!(matches!(error, CompletionError::HttpError(_)));
3262        assert_eq!(
3263            error.provider_response_status(),
3264            Some(http::StatusCode::TOO_MANY_REQUESTS)
3265        );
3266        assert_eq!(error.provider_response_body(), Some(body));
3267        let json = error
3268            .provider_response_json()
3269            .expect("raw body should be valid JSON")
3270            .expect("parsed JSON should be present");
3271        assert_eq!(json["error"]["type"], "rate_limit_error");
3272    }
3273}