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