Skip to main content

rig_core/providers/openai/completion/
mod.rs

1//! Chat Completions message types, model identifiers, and request conversion.
2//!
3//! ```
4//! use rig_core::providers::openai::completion::Message;
5//! let message = Message::system("Answer briefly.");
6//! ```
7
8use crate::completion::CompletionRequest as CoreCompletionRequest;
9use crate::error::EncodeError;
10use crate::json_utils::string_or_vec;
11use crate::message::{AudioMediaType, DocumentSourceKind, ImageDetail, MimeType};
12use crate::{completion, json_utils, message};
13use serde::{Deserialize, Serialize, Serializer};
14use std::convert::Infallible;
15use std::fmt;
16use std::str::FromStr;
17
18/// Serializes user content as a plain string when there's a single text item,
19/// otherwise as an array of content parts.
20fn serialize_user_content<S>(content: &[UserContent], serializer: S) -> Result<S::Ok, S::Error>
21where
22    S: Serializer,
23{
24    if content.len() == 1
25        && let Some(UserContent::Text { text, .. }) = content.first()
26    {
27        return serializer.serialize_str(text);
28    }
29    content.serialize(serializer)
30}
31
32/// GPT-6 Astra, API ID `gpt-6-astra`: a reasoning model. Chat Completions
33/// takes its function tools only at `reasoning_effort: "none"`, which it does
34/// not support, so a Chat request with tools (the extractor's included) is
35/// refused before it is sent: use the Responses wire.
36pub const GPT_6_ASTRA: &str = "gpt-6-astra";
37
38/// GPT-6.1 Sol, API ID `gpt-6.1-sol`: a reasoning model. Chat Completions
39/// takes its function tools only at `reasoning_effort: "none"`, which it does
40/// not support, so a Chat request with tools (the extractor's included) is
41/// refused before it is sent: use the Responses wire.
42pub const GPT_6_1_SOL: &str = "gpt-6.1-sol";
43
44/// GPT-6 Sol, API ID `gpt-6-sol`: a reasoning model. Chat Completions takes
45/// its function tools only at `reasoning_effort: "none"`: a Chat request with
46/// tools is refused before it is sent unless `additional_params` carries
47/// `"reasoning_effort": "none"`. Responses takes them at any effort.
48pub const GPT_6_SOL: &str = "gpt-6-sol";
49
50/// GPT-6 Luna, API ID `gpt-6-luna`: a reasoning model. Chat Completions takes
51/// its function tools only at `reasoning_effort: "none"`: a Chat request with
52/// tools is refused before it is sent unless `additional_params` carries
53/// `"reasoning_effort": "none"`. Responses takes them at any effort.
54pub const GPT_6_LUNA: &str = "gpt-6-luna";
55
56/// `gpt-5.6` completion model (alias that routes to GPT-5.6 Sol)
57pub const GPT_5_6: &str = "gpt-5.6";
58
59/// `gpt-5.6-sol` completion model
60pub const GPT_5_6_SOL: &str = "gpt-5.6-sol";
61
62/// `gpt-5.6-terra` completion model
63pub const GPT_5_6_TERRA: &str = "gpt-5.6-terra";
64
65/// `gpt-5.6-luna` completion model
66pub const GPT_5_6_LUNA: &str = "gpt-5.6-luna";
67
68/// `gpt-5.5` completion model
69pub const GPT_5_5: &str = "gpt-5.5";
70
71/// GPT-5.4, API ID `gpt-5.4`: a reasoning model.
72pub const GPT_5_4: &str = "gpt-5.4";
73
74/// GPT-5.4 mini, API ID `gpt-5.4-mini`: a reasoning model.
75pub const GPT_5_4_MINI: &str = "gpt-5.4-mini";
76
77/// GPT-5.4 nano, API ID `gpt-5.4-nano`: a reasoning model.
78pub const GPT_5_4_NANO: &str = "gpt-5.4-nano";
79
80/// `gpt-5.2` completion model
81pub const GPT_5_2: &str = "gpt-5.2";
82
83/// GPT-5.2 Pro, API ID `gpt-5.2-pro`: a reasoning model served on the
84/// Responses API only, without structured outputs.
85pub const GPT_5_2_PRO: &str = "gpt-5.2-pro";
86
87/// `gpt-5.1` completion model
88pub const GPT_5_1: &str = "gpt-5.1";
89
90/// `gpt-5` completion model
91pub const GPT_5: &str = "gpt-5";
92/// `gpt-5-mini` completion model.
93pub const GPT_5_MINI: &str = "gpt-5-mini";
94/// `gpt-5-nano` completion model.
95pub const GPT_5_NANO: &str = "gpt-5-nano";
96
97/// `gpt-4.5-preview` completion model
98pub const GPT_4_5_PREVIEW: &str = "gpt-4.5-preview";
99/// `gpt-4.5-preview-2025-02-27` completion model
100pub const GPT_4_5_PREVIEW_2025_02_27: &str = "gpt-4.5-preview-2025-02-27";
101/// `gpt-4o-2024-11-20` completion model.
102pub const GPT_4O_2024_11_20: &str = "gpt-4o-2024-11-20";
103/// `gpt-4o` completion model
104pub const GPT_4O: &str = "gpt-4o";
105/// `gpt-4o-mini` completion model
106pub const GPT_4O_MINI: &str = "gpt-4o-mini";
107/// `gpt-4o-2024-05-13` completion model
108pub const GPT_4O_2024_05_13: &str = "gpt-4o-2024-05-13";
109/// `gpt-4-turbo` completion model
110pub const GPT_4_TURBO: &str = "gpt-4-turbo";
111/// `gpt-4-turbo-2024-04-09` completion model
112pub const GPT_4_TURBO_2024_04_09: &str = "gpt-4-turbo-2024-04-09";
113/// `gpt-4-turbo-preview` completion model
114pub const GPT_4_TURBO_PREVIEW: &str = "gpt-4-turbo-preview";
115/// `gpt-4-0125-preview` completion model
116pub const GPT_4_0125_PREVIEW: &str = "gpt-4-0125-preview";
117/// `gpt-4-1106-preview` completion model
118pub const GPT_4_1106_PREVIEW: &str = "gpt-4-1106-preview";
119/// `gpt-4-vision-preview` completion model
120pub const GPT_4_VISION_PREVIEW: &str = "gpt-4-vision-preview";
121/// `gpt-4-1106-vision-preview` completion model
122pub const GPT_4_1106_VISION_PREVIEW: &str = "gpt-4-1106-vision-preview";
123/// `gpt-4` completion model
124pub const GPT_4: &str = "gpt-4";
125/// `gpt-4-0613` completion model
126pub const GPT_4_0613: &str = "gpt-4-0613";
127/// `gpt-4-32k` completion model
128pub const GPT_4_32K: &str = "gpt-4-32k";
129/// `gpt-4-32k-0613` completion model
130pub const GPT_4_32K_0613: &str = "gpt-4-32k-0613";
131
132/// `o4-mini-2025-04-16` completion model
133pub const O4_MINI_2025_04_16: &str = "o4-mini-2025-04-16";
134/// `o4-mini` completion model
135pub const O4_MINI: &str = "o4-mini";
136/// `o3` completion model
137pub const O3: &str = "o3";
138/// `o3-mini` completion model
139pub const O3_MINI: &str = "o3-mini";
140/// `o3-mini-2025-01-31` completion model
141pub const O3_MINI_2025_01_31: &str = "o3-mini-2025-01-31";
142/// `o1-pro` completion model
143pub const O1_PRO: &str = "o1-pro";
144/// `o1` completion model.
145pub const O1: &str = "o1";
146/// `o1-2024-12-17` completion model
147pub const O1_2024_12_17: &str = "o1-2024-12-17";
148/// `o1-preview` completion model
149pub const O1_PREVIEW: &str = "o1-preview";
150/// `o1-preview-2024-09-12` completion model
151pub const O1_PREVIEW_2024_09_12: &str = "o1-preview-2024-09-12";
152/// `o1-mini` completion model.
153pub const O1_MINI: &str = "o1-mini";
154/// `o1-mini-2024-09-12` completion model
155pub const O1_MINI_2024_09_12: &str = "o1-mini-2024-09-12";
156
157/// `gpt-4.1-mini` completion model
158pub const GPT_4_1_MINI: &str = "gpt-4.1-mini";
159/// `gpt-4.1-nano` completion model
160pub const GPT_4_1_NANO: &str = "gpt-4.1-nano";
161/// `gpt-4.1-2025-04-14` completion model
162pub const GPT_4_1_2025_04_14: &str = "gpt-4.1-2025-04-14";
163/// `gpt-4.1` completion model
164pub const GPT_4_1: &str = "gpt-4.1";
165
166#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
167#[serde(tag = "role", rename_all = "lowercase")]
168pub enum Message {
169    #[serde(alias = "developer")]
170    System {
171        #[serde(deserialize_with = "string_or_vec")]
172        content: Vec<SystemContent>,
173        #[serde(skip_serializing_if = "Option::is_none")]
174        name: Option<String>,
175    },
176    User {
177        #[serde(
178            deserialize_with = "string_or_vec",
179            serialize_with = "serialize_user_content"
180        )]
181        content: Vec<UserContent>,
182        #[serde(skip_serializing_if = "Option::is_none")]
183        name: Option<String>,
184    },
185    // Gemini-backed OpenAI-compatible gateways (e.g. OpenRouter) can answer
186    // with `role: "model"`; accept it on deserialization.
187    #[serde(alias = "model", deserialize_with = "deserialize_assistant")]
188    Assistant {
189        #[serde(
190            skip_serializing_if = "Vec::is_empty",
191            serialize_with = "serialize_assistant_content_vec"
192        )]
193        content: Vec<AssistantContent>,
194        // OpenAI-compatible providers expose hidden reasoning on this non-standard
195        // field, and some require it to be echoed back on assistant tool-call turns.
196        // Serialized as `reasoning_content` (llama.cpp/DeepSeek dialect); decoded
197        // from either that key or OpenRouter's `reasoning`.
198        #[serde(skip_serializing_if = "Option::is_none", rename = "reasoning_content")]
199        reasoning: Option<String>,
200        #[serde(skip_serializing_if = "Option::is_none")]
201        refusal: Option<String>,
202        #[serde(skip_serializing_if = "Option::is_none")]
203        name: Option<String>,
204        #[serde(skip_serializing_if = "Vec::is_empty")]
205        tool_calls: Vec<ToolCall>,
206        /// Structured reasoning blocks used by OpenAI-compatible providers
207        /// such as OpenRouter. Empty (and omitted from the wire) for
208        /// providers that do not emit or accept them.
209        #[serde(skip_serializing_if = "Vec::is_empty")]
210        reasoning_details: Vec<ReasoningDetails>,
211    },
212    #[serde(rename = "tool")]
213    ToolResult {
214        tool_call_id: String,
215        content: ToolResultContentValue,
216    },
217}
218
219/// An assistant message as compatible providers send it. The two reasoning
220/// keys are separate fields because gateways relaying a `reasoning_content`
221/// upstream behind a `reasoning` surface send both.
222#[derive(Deserialize)]
223struct AssistantMessageWire {
224    #[serde(default, deserialize_with = "json_utils::string_or_vec")]
225    content: Vec<AssistantContent>,
226    #[serde(default)]
227    reasoning_content: Option<String>,
228    #[serde(default)]
229    reasoning: Option<String>,
230    #[serde(default)]
231    refusal: Option<String>,
232    #[serde(default)]
233    name: Option<String>,
234    #[serde(default, deserialize_with = "json_utils::null_or_default")]
235    tool_calls: Vec<ToolCall>,
236    #[serde(default)]
237    reasoning_details: Vec<ReasoningDetails>,
238}
239
240/// The fields of [`Message::Assistant`], in declaration order.
241type AssistantFields = (
242    Vec<AssistantContent>,
243    Option<String>,
244    Option<String>,
245    Option<String>,
246    Vec<ToolCall>,
247    Vec<ReasoningDetails>,
248);
249
250/// Decode [`Message::Assistant`], preferring `reasoning_content` over
251/// `reasoning` as the streamed delta does.
252fn deserialize_assistant<'de, D>(deserializer: D) -> Result<AssistantFields, D::Error>
253where
254    D: serde::Deserializer<'de>,
255{
256    let wire = AssistantMessageWire::deserialize(deserializer)?;
257    Ok((
258        wire.content,
259        wire.reasoning_content.or(wire.reasoning),
260        wire.refusal,
261        wire.name,
262        wire.tool_calls,
263        wire.reasoning_details,
264    ))
265}
266
267impl Message {
268    pub fn system(content: &str) -> Self {
269        Message::System {
270            content: vec![content.to_owned().into()],
271            name: None,
272        }
273    }
274}
275
276fn history_contains_tool_result(messages: &[Message]) -> bool {
277    messages
278        .iter()
279        .any(|message| matches!(message, Message::ToolResult { .. }))
280}
281
282/// Structured reasoning blocks attached to assistant messages by
283/// OpenAI-compatible providers such as OpenRouter (`reasoning_details`).
284///
285/// The `Option` fields are intentionally serialized even when `None`
286/// (`"format":null,"id":null`) to match the provider wire format.
287#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
288#[serde(tag = "type", rename_all = "snake_case")]
289pub enum ReasoningDetails {
290    #[serde(rename = "reasoning.summary")]
291    Summary {
292        id: Option<String>,
293        format: Option<String>,
294        index: Option<usize>,
295        summary: String,
296    },
297    #[serde(rename = "reasoning.encrypted")]
298    Encrypted {
299        id: Option<String>,
300        format: Option<String>,
301        index: Option<usize>,
302        data: String,
303    },
304    #[serde(rename = "reasoning.text")]
305    Text {
306        id: Option<String>,
307        format: Option<String>,
308        index: Option<usize>,
309        text: Option<String>,
310        signature: Option<String>,
311    },
312}
313
314#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
315pub struct SystemContent {
316    #[serde(default)]
317    pub r#type: SystemContentType,
318    pub text: String,
319}
320
321#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
322#[serde(rename_all = "lowercase")]
323pub enum SystemContentType {
324    #[default]
325    Text,
326}
327
328#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
329#[serde(tag = "type", rename_all = "lowercase")]
330pub enum AssistantContent {
331    Text { text: String },
332    Refusal { refusal: String },
333}
334
335impl From<AssistantContent> for completion::AssistantContent {
336    fn from(value: AssistantContent) -> Self {
337        match value {
338            AssistantContent::Text { text, .. } => completion::AssistantContent::text(text),
339            AssistantContent::Refusal { refusal } => completion::AssistantContent::text(refusal),
340        }
341    }
342}
343
344#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
345#[serde(tag = "type", rename_all = "lowercase")]
346pub enum UserContent {
347    Text {
348        text: String,
349    },
350    #[serde(rename = "image_url")]
351    Image {
352        image_url: ImageUrl,
353    },
354    /// Audio content part, OpenAI's `input_audio` wire tag.
355    #[serde(rename = "input_audio")]
356    Audio {
357        input_audio: InputAudio,
358    },
359    /// File content part for documents such as PDFs.
360    ///
361    /// Maps to OpenAI's `{"type":"file","file":{...}}` content type. Either
362    /// `file_data` (a base64 data URI like `data:application/pdf;base64,...`)
363    /// or `file_id` (a previously uploaded file reference) must be set.
364    File {
365        file: FileData,
366    },
367    /// Video content part (URL or base64 data URI), used by OpenAI-compatible
368    /// providers such as OpenRouter. Wire tag: `video_url`.
369    #[serde(rename = "video_url")]
370    Video {
371        video_url: VideoUrl,
372    },
373}
374
375#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
376pub struct ImageUrl {
377    pub url: String,
378    /// Image detail level. Optional so that providers whose wire format omits
379    /// it (e.g. OpenRouter) can leave the key out entirely.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub detail: Option<ImageDetail>,
382}
383
384/// Video payload for [`UserContent::Video`].
385///
386/// `url` is either a publicly accessible URL or a base64 data URI
387/// (e.g. `data:video/mp4;base64,...`).
388#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
389pub struct VideoUrl {
390    pub url: String,
391}
392
393#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
394pub struct InputAudio {
395    pub data: String,
396    pub format: AudioMediaType,
397}
398
399/// File payload for [`UserContent::File`].
400///
401/// At least one of `file_data` or `file_id` must be set for the content part
402/// to be accepted by OpenAI's chat completions API. `filename` is optional
403/// but recommended.
404#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
405pub struct FileData {
406    /// Inline file data as a base64 data URI, e.g.
407    /// `data:application/pdf;base64,JVBERi0xLjQK...`.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub file_data: Option<String>,
410    /// Identifier of a previously uploaded file (OpenAI Files API).
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub file_id: Option<String>,
413    /// Display name of the file. Recommended for inline `file_data`.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub filename: Option<String>,
416}
417
418/// Text or image content in a tool-result message.
419/// Image emission requires
420/// [`Quirks::supports_image_tool_results`](crate::providers::openai::wire::Quirks::supports_image_tool_results).
421#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
422#[serde(tag = "type")]
423pub enum ToolResultContent {
424    #[serde(rename = "text")]
425    Text { text: String },
426    #[serde(rename = "image_url")]
427    Image { image_url: ImageUrl },
428}
429
430impl ToolResultContent {
431    /// The text of this part, or `None` for a non-text part.
432    pub fn as_text(&self) -> Option<&str> {
433        match self {
434            Self::Text { text } => Some(text.as_str()),
435            Self::Image { .. } => None,
436        }
437    }
438}
439
440impl FromStr for ToolResultContent {
441    type Err = Infallible;
442
443    fn from_str(s: &str) -> Result<Self, Self::Err> {
444        Ok(s.to_owned().into())
445    }
446}
447
448impl From<String> for ToolResultContent {
449    fn from(s: String) -> Self {
450        ToolResultContent::Text { text: s }
451    }
452}
453
454#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
455#[serde(untagged)]
456pub enum ToolResultContentValue {
457    Array(Vec<ToolResultContent>),
458    String(String),
459}
460
461impl ToolResultContentValue {
462    /// Join text parts with newlines, discarding images. String values are cloned.
463    pub fn as_text(&self) -> String {
464        match self {
465            ToolResultContentValue::Array(arr) => arr
466                .iter()
467                .filter_map(ToolResultContent::as_text)
468                .collect::<Vec<_>>()
469                .join("\n"),
470            ToolResultContentValue::String(s) => s.clone(),
471        }
472    }
473
474    /// Whether any part of this result is an image.
475    pub fn has_image(&self) -> bool {
476        matches!(self, ToolResultContentValue::Array(arr)
477            if arr.iter().any(|c| matches!(c, ToolResultContent::Image { .. })))
478    }
479
480    pub fn to_array(&self) -> Self {
481        match self {
482            ToolResultContentValue::Array(_) => self.clone(),
483            ToolResultContentValue::String(s) => {
484                ToolResultContentValue::Array(vec![ToolResultContent::from(s.clone())])
485            }
486        }
487    }
488}
489
490#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
491pub struct ToolCall {
492    pub id: String,
493    #[serde(default)]
494    pub r#type: ToolType,
495    pub function: Function,
496}
497
498#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
499#[serde(rename_all = "lowercase")]
500pub enum ToolType {
501    #[default]
502    Function,
503}
504
505/// Function definition for a tool, with optional strict mode
506#[derive(Debug, Deserialize, Serialize, Clone)]
507pub struct FunctionDefinition {
508    pub name: String,
509    pub description: String,
510    pub parameters: serde_json::Value,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub strict: Option<bool>,
513}
514
515#[derive(Debug, Deserialize, Serialize, Clone)]
516pub struct ToolDefinition {
517    pub r#type: String,
518    pub function: FunctionDefinition,
519}
520
521impl From<completion::ToolDefinition> for ToolDefinition {
522    fn from(tool: completion::ToolDefinition) -> Self {
523        Self {
524            r#type: "function".into(),
525            function: FunctionDefinition {
526                name: tool.name,
527                description: tool.description,
528                parameters: tool.parameters,
529                strict: None,
530            },
531        }
532    }
533}
534
535impl ToolDefinition {
536    /// Apply strict mode to this tool definition.
537    /// This sets `strict: true` and sanitizes the schema to meet OpenAI requirements.
538    pub fn with_strict(mut self) -> Self {
539        self.function.strict = Some(true);
540        super::sanitize_schema(&mut self.function.parameters);
541        self
542    }
543}
544
545#[derive(Default, Clone, Debug, PartialEq)]
546pub enum ToolChoice {
547    #[default]
548    Auto,
549    None,
550    Required,
551    /// Force the model to call one specific function:
552    /// `{"type": "function", "function": {"name": "..."}}`.
553    Function {
554        name: String,
555    },
556}
557
558#[derive(Deserialize, Serialize)]
559struct ToolChoiceFunctionName {
560    name: String,
561}
562
563#[derive(Deserialize, Serialize)]
564#[serde(tag = "type", rename_all = "snake_case")]
565enum ToolChoiceFunctionRepr {
566    Function { function: ToolChoiceFunctionName },
567}
568
569impl Serialize for ToolChoice {
570    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
571        match self {
572            Self::Auto => serializer.serialize_str("auto"),
573            Self::None => serializer.serialize_str("none"),
574            Self::Required => serializer.serialize_str("required"),
575            Self::Function { name } => ToolChoiceFunctionRepr::Function {
576                function: ToolChoiceFunctionName { name: name.clone() },
577            }
578            .serialize(serializer),
579        }
580    }
581}
582
583impl<'de> Deserialize<'de> for ToolChoice {
584    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
585        #[derive(Deserialize)]
586        #[serde(untagged)]
587        enum Repr {
588            Mode(String),
589            Function(ToolChoiceFunctionRepr),
590        }
591
592        match Repr::deserialize(deserializer)? {
593            Repr::Mode(mode) => match mode.as_str() {
594                "auto" => Ok(Self::Auto),
595                "none" => Ok(Self::None),
596                "required" => Ok(Self::Required),
597                other => Err(serde::de::Error::custom(format!(
598                    "unknown tool_choice mode {other:?}"
599                ))),
600            },
601            Repr::Function(ToolChoiceFunctionRepr::Function {
602                function: ToolChoiceFunctionName { name },
603            }) => Ok(Self::Function { name }),
604        }
605    }
606}
607
608impl ToolChoice {
609    /// Force a call to the named function.
610    pub fn function(name: impl Into<String>) -> Self {
611        Self::Function { name: name.into() }
612    }
613}
614
615impl TryFrom<crate::message::ToolChoice> for ToolChoice {
616    type Error = EncodeError;
617    fn try_from(value: crate::message::ToolChoice) -> Result<Self, Self::Error> {
618        let res = match value {
619            message::ToolChoice::Specific { function_names } => {
620                let [name] = function_names.as_slice() else {
621                    return Err(EncodeError::request(
622                        "Provider only supports forcing exactly one specific tool".to_string(),
623                    ));
624                };
625                Self::function(name)
626            }
627            message::ToolChoice::Auto => Self::Auto,
628            message::ToolChoice::None => Self::None,
629            message::ToolChoice::Required => Self::Required,
630        };
631
632        Ok(res)
633    }
634}
635
636#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
637pub struct Function {
638    pub name: String,
639    #[serde(
640        serialize_with = "json_utils::stringified_json::serialize",
641        deserialize_with = "json_utils::stringified_json::deserialize_maybe_stringified"
642    )]
643    pub arguments: serde_json::Value,
644}
645
646impl TryFrom<message::ToolResult> for Message {
647    type Error = message::MessageError;
648
649    fn try_from(value: message::ToolResult) -> Result<Self, Self::Error> {
650        // Single-item conversion supplies a candidate. The full-request
651        // builder applies occurrence-scoped IDs to both calls and results.
652        let tool_call_id = value.call.wire().into_owned();
653        let parts = value
654            .content
655            .into_iter()
656            .map(|content| match content {
657                message::ToolResultContent::Text(message::Text { text, .. }) => {
658                    Ok(ToolResultContent::from(text))
659                }
660                message::ToolResultContent::Json { value } => {
661                    Ok(ToolResultContent::from(value.to_string()))
662                }
663                // The request builder checks image support using the selected dialect.
664                message::ToolResultContent::Image(message::Image {
665                    data,
666                    media_type,
667                    detail,
668                    ..
669                }) => {
670                    let url = match data {
671                        DocumentSourceKind::Url(url) => url,
672                        DocumentSourceKind::Base64(data) => {
673                            let media_type = media_type.ok_or_else(|| {
674                                message::MessageError::ConversionError(
675                                    "a base64 image in a tool result needs a media type to build \
676                                     its data URI"
677                                        .into(),
678                                )
679                            })?;
680                            format!("data:{};base64,{}", media_type.to_mime_type(), data)
681                        }
682                        // Error messages must not expose raw image bytes.
683                        DocumentSourceKind::Raw(_) => {
684                            return Err(message::MessageError::ConversionError(
685                                "raw image bytes are not supported in a tool result; encode as \
686                                 base64 first"
687                                    .into(),
688                            ));
689                        }
690                        // Source values may contain private caller data.
691                        DocumentSourceKind::FileId(_) => {
692                            return Err(message::MessageError::ConversionError(
693                                "a provider-side file id is not supported in a tool result on \
694                                 this surface; use a URL or base64"
695                                    .into(),
696                            ));
697                        }
698                        DocumentSourceKind::String(_) | DocumentSourceKind::Unknown => {
699                            return Err(message::MessageError::ConversionError(
700                                "this image carries no usable source; use a URL or base64".into(),
701                            ));
702                        }
703                    };
704                    Ok(ToolResultContent::Image {
705                        image_url: ImageUrl { url, detail },
706                    })
707                }
708            })
709            .collect::<Result<Vec<_>, _>>()?;
710
711        // Only a lone *text* part flattens to a bare string; an image has no
712        // string form, so flattening it would silently discard it.
713        let content = match parts.as_slice() {
714            [ToolResultContent::Text { text }] => ToolResultContentValue::String(text.clone()),
715            _ => ToolResultContentValue::Array(parts),
716        };
717
718        Ok(Message::ToolResult {
719            tool_call_id,
720            content,
721        })
722    }
723}
724
725impl TryFrom<message::UserContent> for UserContent {
726    type Error = message::MessageError;
727
728    fn try_from(value: message::UserContent) -> Result<Self, Self::Error> {
729        match value {
730            message::UserContent::Text(message::Text { text, .. }) => Ok(UserContent::Text { text }),
731            message::UserContent::Image(message::Image {
732                data,
733                detail,
734                media_type,
735                ..
736            }) => match data {
737                DocumentSourceKind::Url(url) => Ok(UserContent::Image {
738                    image_url: ImageUrl {
739                        url,
740                        // OpenAI's wire format always carries a detail level;
741                        // absent rig-level detail maps to the default (auto).
742                        detail: Some(detail.unwrap_or_default()),
743                    },
744                }),
745                DocumentSourceKind::Base64(data) => {
746                    let url = format!(
747                        "data:{};base64,{}",
748                        media_type.map(|i| i.to_mime_type()).ok_or(
749                            message::MessageError::ConversionError(
750                                "OpenAI Image URI must have media type".into()
751                            )
752                        )?,
753                        data
754                    );
755
756                    let detail = Some(detail.unwrap_or_default());
757
758                    Ok(UserContent::Image {
759                        image_url: ImageUrl { url, detail },
760                    })
761                }
762                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
763                    "Raw files not supported, encode as base64 first".into(),
764                )),
765                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
766                    "File IDs are not supported for images".into(),
767                )),
768                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
769                    "Document has no body".into(),
770                )),
771                doc => Err(message::MessageError::ConversionError(format!(
772                    "Unsupported document type: {doc:?}"
773                ))),
774            },
775            message::UserContent::Document(message::Document {
776                data: DocumentSourceKind::FileId(file_id),
777                ..
778            }) => Ok(UserContent::File {
779                file: FileData {
780                    file_data: None,
781                    file_id: Some(file_id),
782                    filename: None,
783                },
784            }),
785            message::UserContent::Document(message::Document {
786                data,
787                media_type: Some(message::DocumentMediaType::PDF),
788                ..
789            }) => match data {
790                DocumentSourceKind::Base64(b64) => Ok(UserContent::File {
791                    file: FileData {
792                        file_data: Some(format!("data:application/pdf;base64,{b64}")),
793                        file_id: None,
794                        filename: Some("document.pdf".to_string()),
795                    },
796                }),
797                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
798                    "OpenAI chat completions does not accept URL files; use the Responses API or pass base64-encoded bytes".into(),
799                )),
800                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
801                    "Raw files not supported, encode as base64 first".into(),
802                )),
803                DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
804                    "PDF documents must be base64-encoded, not raw strings".into(),
805                )),
806                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
807                    "File ID documents should be converted without media type constraints".into(),
808                )),
809                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
810                    "Document has no body".into(),
811                )),
812            },
813            message::UserContent::Document(message::Document { data, .. }) => {
814                if let DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text) = data {
815                    Ok(UserContent::Text { text })
816                } else {
817                    Err(message::MessageError::ConversionError(
818                        "Documents must be base64 or a string".into(),
819                    ))
820                }
821            }
822            message::UserContent::Audio(message::Audio {
823                data, media_type, ..
824            }) => match data {
825                DocumentSourceKind::Base64(data) => Ok(UserContent::Audio {
826                    input_audio: InputAudio {
827                        data,
828                        format: media_type.unwrap_or(AudioMediaType::MP3),
829                    },
830                }),
831                DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
832                    "URLs are not supported for audio".into(),
833                )),
834                DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
835                    "Raw files are not supported for audio".into(),
836                )),
837                DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
838                    "File IDs are not supported for audio".into(),
839                )),
840                DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
841                    "Audio has no body".into(),
842                )),
843                audio => Err(message::MessageError::ConversionError(format!(
844                    "Unsupported audio type: {audio:?}"
845                ))),
846            },
847            message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
848                "Tool result is in unsupported format".into(),
849            )),
850            message::UserContent::Video(message::Video {
851                data, media_type, ..
852            }) => {
853                let url = match data {
854                    DocumentSourceKind::Url(url) => url,
855                    DocumentSourceKind::Base64(data) => {
856                        let mime = media_type
857                            .ok_or_else(|| {
858                                message::MessageError::ConversionError(
859                                    "Video media type required for base64 encoding".into(),
860                                )
861                            })?
862                            .to_mime_type();
863                        format!("data:{mime};base64,{data}")
864                    }
865                    DocumentSourceKind::Raw(_) => {
866                        return Err(message::MessageError::ConversionError(
867                            "Raw bytes not supported for video, encode as base64 first".into(),
868                        ));
869                    }
870                    DocumentSourceKind::FileId(_) => {
871                        return Err(message::MessageError::ConversionError(
872                            "File IDs are not supported for video".into(),
873                        ));
874                    }
875                    DocumentSourceKind::String(_) => {
876                        return Err(message::MessageError::ConversionError(
877                            "String source not supported for video".into(),
878                        ));
879                    }
880                    DocumentSourceKind::Unknown => {
881                        return Err(message::MessageError::ConversionError(
882                            "Video has no data".into(),
883                        ));
884                    }
885                };
886                Ok(UserContent::Video {
887                    video_url: VideoUrl { url },
888                })
889            }
890        }
891    }
892}
893
894/// Convert user content into ordered user and tool-result messages.
895/// Group adjacent non-tool content. Return conversion errors for unsupported media.
896pub fn user_content_to_messages(
897    value: impl IntoIterator<Item = message::UserContent>,
898) -> Result<Vec<Message>, message::MessageError> {
899    fn flush_user_content(messages: &mut Vec<Message>, pending: &mut Vec<UserContent>) {
900        // Consecutive tool results must not introduce empty user messages.
901        if pending.is_empty() {
902            return;
903        }
904
905        messages.push(Message::User {
906            content: std::mem::take(pending),
907            name: None,
908        });
909    }
910
911    let mut messages = Vec::new();
912    let mut pending = Vec::new();
913
914    for content in value {
915        match content {
916            message::UserContent::ToolResult(tool_result) => {
917                flush_user_content(&mut messages, &mut pending);
918                messages.push(tool_result.try_into()?);
919            }
920            content => pending.push(content.try_into()?),
921        }
922    }
923
924    flush_user_content(&mut messages, &mut pending);
925    Ok(messages)
926}
927
928/// Convert assistant content into at most one message, rejecting images.
929/// When `reasoning_details` is true, preserve structured reasoning parts and
930/// signatures; otherwise use display text. Return no message when text, calls,
931/// and structured details are all empty.
932pub fn assistant_content_to_messages(
933    value: impl IntoIterator<Item = message::AssistantContent>,
934    reasoning_details: bool,
935    issuers: &[message::Issuer],
936) -> Result<Vec<Message>, message::MessageError> {
937    let mut text_content = Vec::new();
938    let mut tool_calls = Vec::new();
939    // Distinct reasoning blocks are joined with a newline (matching
940    // `display_text()`'s own inter-block separator) rather than glued
941    // together, so replayed multi-block reasoning keeps its boundaries.
942    let mut reasoning_parts: Vec<String> = Vec::new();
943    let mut details: Vec<ReasoningDetails> = Vec::new();
944
945    for content in value {
946        match content {
947            message::AssistantContent::Text(text) => text_content.push(text),
948            message::AssistantContent::ToolCall(tool_call) => tool_calls.push(tool_call),
949            // Reasoning another service issued is not replayed here.
950            message::AssistantContent::Reasoning(sealed) => {
951                let Some(reasoning) = sealed.open_for(issuers) else {
952                    continue;
953                };
954                if !reasoning_details || reasoning.content.is_empty() {
955                    let display = reasoning.display_text();
956                    if !display.is_empty() {
957                        reasoning_parts.push(display);
958                    }
959                    continue;
960                }
961                // Structured replay preserves signatures and encrypted payloads.
962                // A block the stream aggregated without a wire id carries the
963                // accumulator's shared "" identity; it replays as a null id,
964                // the shape the provider's own unary body uses.
965                let id = reasoning.id.clone().filter(|id| !id.is_empty());
966                // `index` numbers the entries across the whole message, the
967                // way the provider numbers the array it sent.
968                let base = details.len();
969                let entries = reasoning.content.iter().enumerate().map(|(offset, part)| {
970                    let id = id.clone();
971                    let index = Some(base + offset);
972                    match part {
973                        message::ReasoningContent::Text { text, signature } => {
974                            ReasoningDetails::Text {
975                                id,
976                                format: None,
977                                index,
978                                text: Some(text.clone()),
979                                signature: signature.clone(),
980                            }
981                        }
982                        message::ReasoningContent::Summary(summary) => ReasoningDetails::Summary {
983                            id,
984                            format: None,
985                            index,
986                            summary: summary.clone(),
987                        },
988                        message::ReasoningContent::Encrypted(data)
989                        | message::ReasoningContent::Redacted { data } => {
990                            ReasoningDetails::Encrypted {
991                                id,
992                                format: None,
993                                index,
994                                data: data.clone(),
995                            }
996                        }
997                    }
998                });
999                details.extend(entries);
1000            }
1001            message::AssistantContent::Image(_) => {
1002                return Err(message::MessageError::ConversionError(
1003                    "OpenAI assistant messages do not support image content in chat completions"
1004                        .into(),
1005                ));
1006            }
1007        }
1008    }
1009
1010    // A details-only assistant message is not an empty turn: it is exactly
1011    // the signed-reasoning echo the dialect requires before the tool call it
1012    // precedes, and dropping it loses the signature.
1013    if text_content.is_empty() && tool_calls.is_empty() && details.is_empty() {
1014        return Ok(vec![]);
1015    }
1016
1017    Ok(vec![Message::Assistant {
1018        content: text_content
1019            .into_iter()
1020            .map(|content| content.text.into())
1021            .collect::<Vec<_>>(),
1022        reasoning: if reasoning_parts.is_empty() {
1023            None
1024        } else {
1025            Some(reasoning_parts.join("\n"))
1026        },
1027        refusal: None,
1028        name: None,
1029        tool_calls: tool_calls
1030            .into_iter()
1031            .map(std::convert::Into::into)
1032            .collect::<Vec<_>>(),
1033        reasoning_details: details,
1034    }])
1035}
1036
1037impl TryFrom<message::Message> for Vec<Message> {
1038    type Error = message::MessageError;
1039
1040    /// The dialect-agnostic conversion. It names no issuer, so no reasoning
1041    /// opens and none is replayed; the wire's own conversion
1042    /// ([`OpenAIRequestParams`]) passes the dialect's issuers and answer.
1043    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1044        match message {
1045            message::Message::System { content } => Ok(vec![Message::system(&content)]),
1046            message::Message::User { content } => user_content_to_messages(content),
1047            message::Message::Assistant { content, .. } => {
1048                assistant_content_to_messages(content, false, &[])
1049            }
1050        }
1051    }
1052}
1053
1054fn message_with_tool_ids(
1055    source: message::Message,
1056    position: usize,
1057    ids: &crate::providers::internal::wire_ids::WireIds,
1058    reasoning_details: bool,
1059    issuers: &[message::Issuer],
1060) -> Result<Vec<Message>, message::MessageError> {
1061    let content_positions: Vec<_> = match &source {
1062        message::Message::Assistant { content, .. } => content
1063            .iter()
1064            .enumerate()
1065            .filter_map(|(index, part)| {
1066                matches!(part, message::AssistantContent::ToolCall(_)).then_some(index)
1067            })
1068            .collect(),
1069        message::Message::User { content } => content
1070            .iter()
1071            .enumerate()
1072            .filter_map(|(index, part)| {
1073                matches!(part, message::UserContent::ToolResult(_)).then_some(index)
1074            })
1075            .collect(),
1076        message::Message::System { .. } => Vec::new(),
1077    };
1078    let mut converted: Vec<Message> = match source {
1079        message::Message::Assistant { content, .. } => {
1080            assistant_content_to_messages(content, reasoning_details, issuers)?
1081        }
1082        source => source.try_into()?,
1083    };
1084    // Conversion can split text into separate messages, but retains every tool
1085    // call/result in source order. Assign only wire fields, never core provenance.
1086    let slots: Vec<&mut String> = converted
1087        .iter_mut()
1088        .flat_map(|message| match message {
1089            Message::Assistant { tool_calls, .. } => {
1090                tool_calls.iter_mut().map(|call| &mut call.id).collect()
1091            }
1092            Message::ToolResult { tool_call_id, .. } => vec![tool_call_id],
1093            _ => Vec::new(),
1094        })
1095        .collect();
1096    if slots.len() != content_positions.len() {
1097        return Err(message::MessageError::ConversionError(
1098            "tool identity mapping lost a content occurrence during OpenAI conversion".into(),
1099        ));
1100    }
1101    for (slot, content) in slots.into_iter().zip(content_positions) {
1102        *slot = ids
1103            .get(position, content)
1104            .ok_or_else(|| {
1105                message::MessageError::ConversionError(
1106                    "missing planned OpenAI tool identity".into(),
1107                )
1108            })?
1109            .to_owned();
1110    }
1111    Ok(converted)
1112}
1113
1114impl From<message::ToolCall> for ToolCall {
1115    fn from(tool_call: message::ToolCall) -> Self {
1116        Self {
1117            // Use the same wire-handle selection as tool-result conversion.
1118            id: tool_call.id.wire().into_owned(),
1119            r#type: ToolType::default(),
1120            function: Function {
1121                name: tool_call.function.name.into(),
1122                arguments: tool_call.function.arguments,
1123            },
1124        }
1125    }
1126}
1127
1128impl From<String> for UserContent {
1129    fn from(s: String) -> Self {
1130        UserContent::Text { text: s }
1131    }
1132}
1133
1134impl From<&str> for UserContent {
1135    fn from(s: &str) -> Self {
1136        s.to_owned().into()
1137    }
1138}
1139
1140impl FromStr for UserContent {
1141    type Err = Infallible;
1142
1143    fn from_str(s: &str) -> Result<Self, Self::Err> {
1144        Ok(s.to_owned().into())
1145    }
1146}
1147
1148impl From<String> for AssistantContent {
1149    fn from(s: String) -> Self {
1150        AssistantContent::Text { text: s }
1151    }
1152}
1153
1154impl FromStr for AssistantContent {
1155    type Err = Infallible;
1156
1157    fn from_str(s: &str) -> Result<Self, Self::Err> {
1158        Ok(s.to_owned().into())
1159    }
1160}
1161impl From<String> for SystemContent {
1162    fn from(s: String) -> Self {
1163        SystemContent {
1164            r#type: SystemContentType::default(),
1165            text: s,
1166        }
1167    }
1168}
1169
1170impl FromStr for SystemContent {
1171    type Err = Infallible;
1172
1173    fn from_str(s: &str) -> Result<Self, Self::Err> {
1174        Ok(s.to_owned().into())
1175    }
1176}
1177
1178#[derive(Clone, Debug, Deserialize, Serialize)]
1179pub struct CompletionResponse {
1180    pub id: String,
1181    // Null-or-missing tolerated on deserialization: some OpenAI-compatible
1182    // gateways (HuggingFace router sub-providers, TGI variants, Copilot's
1183    // multi-vendor chat route) omit them or send explicit `null`.
1184    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1185    pub object: String,
1186    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1187    pub created: u64,
1188    pub model: String,
1189    pub system_fingerprint: Option<String>,
1190    /// Service tier that processed the request, when OpenAI reports it.
1191    #[serde(default, skip_serializing_if = "Option::is_none")]
1192    pub service_tier: Option<String>,
1193    #[serde(
1194        deserialize_with = "crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls"
1195    )]
1196    pub choices: Vec<Choice>,
1197    pub usage: Option<Usage>,
1198}
1199
1200/// Return a nonempty top-level refusal only when every content part is empty.
1201/// Applies to whole messages; streaming fallback is evaluated per delta.
1202pub(crate) fn assistant_refusal_fallback<'a>(
1203    content: &[AssistantContent],
1204    refusal: Option<&'a str>,
1205) -> Option<&'a str> {
1206    let has_text = content.iter().any(|part| {
1207        !match part {
1208            AssistantContent::Text { text } => text,
1209            AssistantContent::Refusal { refusal } => refusal,
1210        }
1211        .is_empty()
1212    });
1213
1214    refusal.filter(|refusal| !has_text && !refusal.is_empty())
1215}
1216
1217/// The whole-message text view: every non-empty part in arrival order, with
1218/// the sibling `refusal` appended only when [`assistant_refusal_fallback`]
1219/// says it is the turn's text.
1220///
1221/// No wire path reads text this way — the driver records off the folded
1222/// response — so this survives for the OpenAI-compatible providers' unary
1223/// decode tests, which read a decoded message's text through it.
1224#[cfg(test)]
1225pub(crate) fn assistant_message_text_response(message: &Message) -> Option<String> {
1226    let Message::Assistant {
1227        content, refusal, ..
1228    } = message
1229    else {
1230        return None;
1231    };
1232
1233    let mut segments = content
1234        .iter()
1235        .filter_map(|content| match content {
1236            AssistantContent::Text { text, .. } => (!text.is_empty()).then(|| text.clone()),
1237            AssistantContent::Refusal { refusal } => (!refusal.is_empty()).then(|| refusal.clone()),
1238        })
1239        .collect::<Vec<_>>();
1240
1241    if let Some(refusal) = assistant_refusal_fallback(content, refusal.as_deref()) {
1242        segments.push(refusal.to_owned());
1243    }
1244
1245    if segments.is_empty() {
1246        None
1247    } else {
1248        Some(segments.join("\n"))
1249    }
1250}
1251
1252#[derive(Clone, Debug, Serialize, Deserialize)]
1253pub struct Choice {
1254    // Null-or-missing tolerated on deserialization: Copilot's chat route
1255    // (fronting non-OpenAI vendors) can omit either field or send explicit
1256    // `null`; normalization treats "" as absent.
1257    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1258    pub index: usize,
1259    pub message: Message,
1260    pub logprobs: Option<serde_json::Value>,
1261    #[serde(default, deserialize_with = "json_utils::null_or_default")]
1262    pub finish_reason: String,
1263}
1264
1265#[derive(Clone, Copy, Debug, Deserialize, Serialize, Default)]
1266pub struct PromptTokensDetails {
1267    /// Cached tokens from prompt caching
1268    #[serde(default)]
1269    pub cached_tokens: usize,
1270    /// Audio input tokens, defaulting null or missing values to zero.
1271    /// Zero is omitted from serialization. [`Usage::to_normalized`] uses the
1272    /// reported total to determine whether audio is additional to prompt tokens.
1273    #[serde(
1274        default,
1275        deserialize_with = "json_utils::null_or_default",
1276        skip_serializing_if = "is_zero"
1277    )]
1278    pub audio_tokens: usize,
1279    /// Tokens written to cache on this call. `None` means unreported, not zero.
1280    #[serde(default, skip_serializing_if = "Option::is_none")]
1281    pub cache_write_tokens: Option<usize>,
1282}
1283
1284/// Whether a counter is absent-as-zero, for `skip_serializing_if`.
1285fn is_zero(value: &usize) -> bool {
1286    *value == 0
1287}
1288
1289#[derive(Clone, Copy, Debug, Deserialize, Serialize, Default)]
1290pub struct CompletionTokensDetails {
1291    /// Reasoning tokens reported by reasoning-capable providers.
1292    #[serde(default)]
1293    pub reasoning_tokens: usize,
1294}
1295
1296#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1297pub struct Usage {
1298    pub prompt_tokens: usize,
1299    #[serde(default, skip_serializing_if = "Option::is_none")]
1300    pub completion_tokens: Option<usize>,
1301    pub total_tokens: usize,
1302    // Not aliased to Mistral's singular `prompt_token_details`: Mistral's
1303    // embeddings reply carries *both* keys (the singular always `null`), and
1304    // an alias makes serde reject the document as a duplicate field.
1305    #[serde(skip_serializing_if = "Option::is_none")]
1306    pub prompt_tokens_details: Option<PromptTokensDetails>,
1307    #[serde(default, skip_serializing_if = "Option::is_none")]
1308    pub completion_tokens_details: Option<CompletionTokensDetails>,
1309    /// Mistral's top-level cached-prompt count, reported beside (or instead
1310    /// of) `prompt_tokens_details.cached_tokens`.
1311    #[serde(default, skip_serializing_if = "Option::is_none")]
1312    pub num_cached_tokens: Option<u64>,
1313    #[serde(default, skip_serializing_if = "Option::is_none")]
1314    pub queue_time: Option<f64>,
1315    #[serde(default, skip_serializing_if = "Option::is_none")]
1316    pub prompt_time: Option<f64>,
1317    #[serde(default, skip_serializing_if = "Option::is_none")]
1318    pub completion_time: Option<f64>,
1319    #[serde(default, skip_serializing_if = "Option::is_none")]
1320    pub total_time: Option<f64>,
1321}
1322
1323impl Usage {
1324    pub fn new() -> Self {
1325        Self {
1326            prompt_tokens: 0,
1327            completion_tokens: None,
1328            total_tokens: 0,
1329            prompt_tokens_details: None,
1330            completion_tokens_details: None,
1331            num_cached_tokens: None,
1332            queue_time: None,
1333            prompt_time: None,
1334            completion_time: None,
1335            total_time: None,
1336        }
1337    }
1338}
1339
1340impl Default for Usage {
1341    fn default() -> Self {
1342        Self::new()
1343    }
1344}
1345
1346impl fmt::Display for Usage {
1347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1348        let Usage {
1349            prompt_tokens,
1350            total_tokens,
1351            ..
1352        } = self;
1353        write!(
1354            f,
1355            "Prompt tokens: {prompt_tokens} Total tokens: {total_tokens}"
1356        )
1357    }
1358}
1359
1360impl From<&Usage> for crate::completion::Usage {
1361    fn from(value: &Usage) -> crate::completion::Usage {
1362        value.to_normalized()
1363    }
1364}
1365
1366impl From<Usage> for crate::completion::Usage {
1367    fn from(value: Usage) -> crate::completion::Usage {
1368        value.to_normalized()
1369    }
1370}
1371
1372impl Usage {
1373    /// Return prompt tokens plus audio only when that sum and output match the total.
1374    /// Missing output counts are treated as zero for this comparison.
1375    fn input_tokens(&self) -> usize {
1376        let audio = self
1377            .prompt_tokens_details
1378            .map_or(0, |details| details.audio_tokens);
1379        let beside = self.prompt_tokens.saturating_add(audio);
1380        let accounted = beside.saturating_add(self.completion_tokens.unwrap_or(0));
1381        if audio != 0 && accounted == self.total_tokens {
1382            beside
1383        } else {
1384            self.prompt_tokens
1385        }
1386    }
1387
1388    /// Normalize token accounting, deriving absent output counts from the total.
1389    /// Cached input prefers prompt details and falls back to `num_cached_tokens`.
1390    pub fn to_normalized(&self) -> crate::completion::Usage {
1391        let input_tokens = self.input_tokens();
1392        let details = self.prompt_tokens_details.as_ref();
1393        crate::completion::Usage {
1394            input_tokens: Some(input_tokens as u64),
1395            // Gateways that omit `completion_tokens` still send the total, so
1396            // the completion count is the remainder.
1397            output_tokens: Some(
1398                self.completion_tokens
1399                    .unwrap_or_else(|| self.total_tokens.saturating_sub(input_tokens))
1400                    as u64,
1401            ),
1402            total_tokens: Some(self.total_tokens as u64),
1403            cached_input_tokens: details
1404                .map(|d| d.cached_tokens as u64)
1405                .or(self.num_cached_tokens),
1406            cache_creation_input_tokens: details
1407                .and_then(|d| d.cache_write_tokens)
1408                .map(|tokens| tokens as u64),
1409            reasoning_tokens: self
1410                .completion_tokens_details
1411                .as_ref()
1412                .map(|d| d.reasoning_tokens as u64),
1413            ..Default::default()
1414        }
1415    }
1416}
1417
1418/// Whether the model matches the GPT-5 through GPT-9 or numeric o-series rules
1419/// used to select `max_completion_tokens`.
1420pub(crate) fn is_openai_reasoning_model(model: &str) -> bool {
1421    /// Match a single-digit GPT major version at least `lowest`, allowing dot
1422    /// and hyphen suffixes. Multi-digit major versions do not match.
1423    fn is_numbered_gpt_family(model: &str, lowest: u32) -> bool {
1424        model
1425            .strip_prefix("gpt-")
1426            .and_then(|rest| rest.split(['.', '-']).next())
1427            .filter(|major| major.len() == 1)
1428            .and_then(|major| major.parse::<u32>().ok())
1429            .is_some_and(|major| major >= lowest)
1430    }
1431
1432    /// Match `o` followed by a digit and then an end, hyphen, or another digit.
1433    fn is_o_series(model: &str) -> bool {
1434        let mut chars = model.chars();
1435        chars.next() == Some('o')
1436            && chars.next().is_some_and(|digit| digit.is_ascii_digit())
1437            && chars
1438                .next()
1439                .is_none_or(|next| next == '-' || next.is_ascii_digit())
1440    }
1441
1442    is_numbered_gpt_family(model, 5) || is_o_series(model)
1443}
1444
1445/// Serialize a chat-completions request into the body the target endpoint
1446/// expects, applying the spellings that depend on the endpoint rather than on
1447/// the request.
1448///
1449/// Both the unary and the streaming path build their body through here so the
1450/// two cannot disagree about what rig sends.
1451pub(crate) fn request_body(
1452    request: &CompletionRequest,
1453    modern_output_cap: bool,
1454) -> Result<serde_json::Value, EncodeError> {
1455    let mut body = serde_json::to_value(request)?;
1456
1457    if modern_output_cap
1458        && let Some(object) = body.as_object_mut()
1459        && let Some(max_tokens) = object.remove("max_tokens")
1460    {
1461        // Preserve explicit modern caps while removing the rejected legacy key.
1462        object.entry("max_completion_tokens").or_insert(max_tokens);
1463    }
1464
1465    Ok(body)
1466}
1467
1468#[derive(Debug, Serialize, Deserialize, Clone)]
1469pub struct CompletionRequest {
1470    pub model: String,
1471    pub messages: Vec<Message>,
1472    #[serde(skip_serializing_if = "Vec::is_empty")]
1473    pub tools: Vec<ToolDefinition>,
1474    #[serde(skip_serializing_if = "Option::is_none")]
1475    pub tool_choice: Option<ToolChoice>,
1476    #[serde(skip_serializing_if = "Option::is_none")]
1477    pub temperature: Option<f64>,
1478    #[serde(skip_serializing_if = "Option::is_none")]
1479    pub max_tokens: Option<u64>,
1480    #[serde(flatten)]
1481    pub additional_params: Option<serde_json::Value>,
1482}
1483
1484/// Shared helper for provider `finalize_request_body` hooks whose APIs take
1485/// message `content` as a plain string: flattens a content-part array to the
1486/// concatenation of its text parts. When `only_if_all_text` is set, arrays
1487/// containing non-text parts are left untouched (for APIs with their own
1488/// multimodal handling); otherwise non-text parts are dropped.
1489pub(crate) fn flatten_text_content_parts(
1490    content: &mut serde_json::Value,
1491    separator: &str,
1492    only_if_all_text: bool,
1493) {
1494    // Refusals are textual content too; flatten them alongside text parts.
1495    // Checked per key so a null-padded `text` next to a string `refusal`
1496    // still counts as textual.
1497    fn part_text(part: &serde_json::Value) -> Option<&str> {
1498        part.get("text")
1499            .and_then(serde_json::Value::as_str)
1500            .or_else(|| part.get("refusal").and_then(serde_json::Value::as_str))
1501    }
1502
1503    let Some(parts) = content.as_array() else {
1504        return;
1505    };
1506    if only_if_all_text && !parts.iter().all(|part| part_text(part).is_some()) {
1507        return;
1508    }
1509    let mut flattened = String::new();
1510    for text in parts.iter().filter_map(part_text) {
1511        if !flattened.is_empty() {
1512            flattened.push_str(separator);
1513        }
1514        flattened.push_str(text);
1515    }
1516    *content = serde_json::Value::String(flattened);
1517}
1518
1519/// Joins the `text` fields of `type == "text"` content parts, in order.
1520pub(crate) fn joined_text_parts(parts: &[serde_json::Value]) -> String {
1521    parts
1522        .iter()
1523        .filter_map(|part| {
1524            (part.get("type").and_then(serde_json::Value::as_str) == Some("text"))
1525                .then(|| part.get("text").and_then(serde_json::Value::as_str))
1526                .flatten()
1527        })
1528        .collect::<Vec<_>>()
1529        .join("")
1530}
1531
1532/// Remove tool messages, assistant tool calls and reasoning, and empty assistant turns.
1533/// Optionally strip names and flatten content using the supplied separator and
1534/// text-only guard. With `merge_same_role`, join adjacent user or assistant text
1535/// messages of the same role with newlines.
1536pub(crate) fn sanitize_plain_text_history(
1537    messages: &mut Vec<serde_json::Value>,
1538    flatten: Option<(&str, bool)>,
1539    strip_names: bool,
1540    merge_same_role: bool,
1541) {
1542    messages
1543        .retain(|message| message.get("role").and_then(serde_json::Value::as_str) != Some("tool"));
1544
1545    for message in messages.iter_mut() {
1546        let Some(object) = message.as_object_mut() else {
1547            continue;
1548        };
1549        if object.get("role").and_then(serde_json::Value::as_str) == Some("assistant") {
1550            object.remove("tool_calls");
1551            object.remove("reasoning_content");
1552        }
1553        if strip_names {
1554            object.remove("name");
1555        }
1556        if let Some((separator, only_if_all_text)) = flatten
1557            && let Some(content) = object.get_mut("content")
1558        {
1559            flatten_text_content_parts(content, separator, only_if_all_text);
1560        }
1561    }
1562
1563    messages.retain(|message| {
1564        if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
1565            return true;
1566        }
1567        match message.get("content") {
1568            Some(serde_json::Value::String(text)) => !text.is_empty(),
1569            Some(serde_json::Value::Null) | None => false,
1570            Some(_) => true,
1571        }
1572    });
1573
1574    if !merge_same_role {
1575        return;
1576    }
1577
1578    let mut merged: Vec<serde_json::Value> = Vec::with_capacity(messages.len());
1579    for message in std::mem::take(messages) {
1580        let merged_text = if let Some(role) = message
1581            .get("role")
1582            .and_then(serde_json::Value::as_str)
1583            .filter(|role| matches!(*role, "assistant" | "user"))
1584            && let Some(previous) = merged.last()
1585            && previous.get("role").and_then(serde_json::Value::as_str) == Some(role)
1586            && let Some(previous_text) = previous.get("content").and_then(serde_json::Value::as_str)
1587            && let Some(text) = message.get("content").and_then(serde_json::Value::as_str)
1588        {
1589            Some(format!("{previous_text}\n{text}"))
1590        } else {
1591            None
1592        };
1593
1594        if let Some(text) = merged_text
1595            && let Some(previous) = merged.last_mut().and_then(serde_json::Value::as_object_mut)
1596        {
1597            previous.insert("content".to_string(), serde_json::Value::String(text));
1598            continue;
1599        }
1600        merged.push(message);
1601    }
1602    *messages = merged;
1603}
1604
1605pub struct OpenAIRequestParams {
1606    pub model: String,
1607    pub request: CoreCompletionRequest,
1608    pub strict_tools: bool,
1609    pub tool_result_array_content: bool,
1610    /// Whether the endpoint honours an image inside a `role:"tool"` message;
1611    /// see
1612    /// [`Quirks::supports_image_tool_results`](crate::providers::openai::wire::Quirks::supports_image_tool_results).
1613    pub supports_image_tool_results: bool,
1614    /// Maps `output_schema` to `response_format` when true; drops it with a
1615    /// warning when false (providers whose APIs reject `json_schema`).
1616    pub supports_response_format: bool,
1617    /// Whether `response_format` rides beside advertised tools before the
1618    /// first tool result; see
1619    /// [`Quirks::response_format_with_tools`](crate::providers::openai::wire::Quirks::response_format_with_tools).
1620    pub response_format_with_tools: bool,
1621    /// Serializes `tools`/`tool_choice` when true; drops them with a warning
1622    /// when false (providers without tool-calling support).
1623    pub supports_tools: bool,
1624    /// Whether the dialect accepts structured reasoning replay on assistant
1625    /// messages; see
1626    /// [`Quirks::reasoning_details`](crate::providers::openai::wire::Quirks::reasoning_details).
1627    ///
1628    /// When set, a reasoning block replays as a `reasoning_details` entry
1629    /// carrying its signature, encrypted blob or summary; when clear it
1630    /// replays as the plain `reasoning_content` string, because a dialect
1631    /// that never sent the array does not accept it either.
1632    pub reasoning_details: bool,
1633    /// The issuers whose reasoning the request replays; reasoning no issuer
1634    /// here opens is left out.
1635    pub issuers: Vec<message::Issuer>,
1636}
1637
1638impl TryFrom<OpenAIRequestParams> for CompletionRequest {
1639    type Error = EncodeError;
1640
1641    fn try_from(params: OpenAIRequestParams) -> Result<Self, Self::Error> {
1642        let OpenAIRequestParams {
1643            model,
1644            request: req,
1645            strict_tools,
1646            tool_result_array_content,
1647            supports_image_tool_results,
1648            supports_response_format,
1649            response_format_with_tools,
1650            supports_tools,
1651            reasoning_details,
1652            issuers,
1653        } = params;
1654        let chat_history = req.chat_history_with_documents();
1655
1656        let CoreCompletionRequest {
1657            model: request_model,
1658            chat_history: _,
1659            tools,
1660            temperature,
1661            max_tokens,
1662            additional_params,
1663            tool_choice,
1664            output_schema,
1665            ..
1666        } = req;
1667
1668        let partial_history = chat_history;
1669
1670        let tool_ids = crate::providers::internal::wire_ids::WireIds::new(&partial_history);
1671
1672        let mut full_history: Vec<Message> = Vec::new();
1673        full_history.extend(
1674            partial_history
1675                .into_iter()
1676                .enumerate()
1677                .map(|(position, message)| {
1678                    message_with_tool_ids(message, position, &tool_ids, reasoning_details, &issuers)
1679                })
1680                .collect::<Result<Vec<Vec<Message>>, _>>()?
1681                .into_iter()
1682                .flatten(),
1683        );
1684
1685        if full_history.is_empty() {
1686            return Err(EncodeError::request(std::io::Error::new(
1687                std::io::ErrorKind::InvalidInput,
1688                "OpenAI Chat Completions request has no provider-compatible messages after conversion",
1689            )));
1690        }
1691
1692        // Image support is dialect-specific and unavailable during isolated result conversion.
1693        for msg in &mut full_history {
1694            if let Message::ToolResult { content, .. } = msg {
1695                if content.has_image() {
1696                    if !supports_image_tool_results {
1697                        // Reject unsupported images instead of silently removing tool output.
1698                        return Err(EncodeError::request(concat!(
1699                            "this provider does not accept an image in a tool result. ",
1700                            "Official OpenAI refuses it on Chat Completions (and the GPT-5 ",
1701                            "family accepts the request while ignoring the image); use the ",
1702                            "Responses API, which carries images in `function_call_output`, ",
1703                            "or a server that sets `SUPPORTS_IMAGE_TOOL_RESULTS` ",
1704                            "(llama.cpp does)",
1705                        )));
1706                    }
1707                    // An image cannot be flattened to a string, so array form is
1708                    // forced regardless of `tool_result_array_content`.
1709                    *content = content.to_array();
1710                    continue;
1711                }
1712
1713                let normalized = if tool_result_array_content {
1714                    content.to_array()
1715                } else {
1716                    ToolResultContentValue::String(content.as_text())
1717                };
1718
1719                *content = normalized;
1720            }
1721        }
1722
1723        let history_has_tool_result = history_contains_tool_result(&full_history);
1724
1725        let (mut tools, tool_choice) = if supports_tools {
1726            let tool_choice = tool_choice.map(ToolChoice::try_from).transpose()?;
1727            let tools: Vec<ToolDefinition> = tools
1728                .into_iter()
1729                .map(|tool| {
1730                    let def = ToolDefinition::from(tool);
1731                    if strict_tools { def.with_strict() } else { def }
1732                })
1733                .collect();
1734            (tools, tool_choice)
1735        } else {
1736            if !tools.is_empty() {
1737                tracing::warn!("Tool use is not supported by this provider; tools will be ignored");
1738            }
1739            if tool_choice.is_some() {
1740                tracing::warn!("Tool choice is not supported by this provider and will be ignored");
1741            }
1742            (Vec::new(), None)
1743        };
1744
1745        // Merge function tools to prevent flattened parameters from replacing typed tools.
1746        // Leave native tools for dialect-specific request preparation.
1747        let mut additional_params = additional_params;
1748        if supports_tools
1749            && let Some(map) = additional_params
1750                .as_mut()
1751                .and_then(serde_json::Value::as_object_mut)
1752            && let Some(raw_tools) = map.remove("tools")
1753        {
1754            let raw_tools =
1755                serde_json::from_value::<Vec<serde_json::Value>>(raw_tools).map_err(|err| {
1756                    EncodeError::request(format!(
1757                        "Invalid OpenAI Chat Completions `additional_params.tools` payload: {err}"
1758                    ))
1759                })?;
1760            let mut remaining = Vec::new();
1761            for raw_tool in raw_tools {
1762                let is_function_tool =
1763                    raw_tool.get("type").and_then(serde_json::Value::as_str) == Some("function");
1764                if is_function_tool {
1765                    let tool =
1766                        serde_json::from_value::<ToolDefinition>(raw_tool).map_err(|err| {
1767                            EncodeError::request(format!(
1768                                "Invalid function tool in OpenAI Chat Completions \
1769                                 `additional_params.tools`: {err}"
1770                            ))
1771                        })?;
1772                    tools.push(tool);
1773                } else {
1774                    remaining.push(raw_tool);
1775                }
1776            }
1777            if !remaining.is_empty() {
1778                map.insert("tools".to_string(), serde_json::Value::Array(remaining));
1779            }
1780        }
1781
1782        if output_schema.is_some() && !supports_response_format {
1783            tracing::warn!(
1784                "Structured outputs are not supported by this provider; ignoring output_schema"
1785            );
1786        }
1787
1788        // Defer schemas until a tool result exists unless the dialect supports both at once.
1789        let should_apply_response_format = output_schema.is_some()
1790            && supports_response_format
1791            && (response_format_with_tools || tools.is_empty() || history_has_tool_result);
1792
1793        let additional_params = if let Some(schema) = output_schema
1794            && should_apply_response_format
1795        {
1796            let (name, schema_value) = super::structured_output_schema(schema);
1797            let response_format = serde_json::json!({
1798                "response_format": {
1799                    "type": "json_schema",
1800                    "json_schema": {
1801                        "name": name,
1802                        "strict": true,
1803                        "schema": schema_value
1804                    }
1805                }
1806            });
1807            Some(match additional_params {
1808                Some(existing) => json_utils::merge(existing, response_format),
1809                None => response_format,
1810            })
1811        } else {
1812            additional_params
1813        };
1814
1815        // The wire rejects tool_choice without advertised tools.
1816        let tool_choice = tool_choice.filter(|_| !tools.is_empty());
1817        let res = Self {
1818            model: request_model.unwrap_or(model),
1819            messages: full_history,
1820            tools,
1821            tool_choice,
1822            temperature,
1823            max_tokens,
1824            additional_params,
1825        };
1826
1827        Ok(res)
1828    }
1829}
1830
1831fn serialize_assistant_content_vec<S>(
1832    value: &[AssistantContent],
1833    serializer: S,
1834) -> Result<S::Ok, S::Error>
1835where
1836    S: Serializer,
1837{
1838    if value.is_empty() {
1839        serializer.serialize_str("")
1840    } else {
1841        value.serialize(serializer)
1842    }
1843}
1844
1845#[cfg(test)]
1846mod tests;
1847
1848#[cfg(test)]
1849mod image_tool_result_gate_tests;