Skip to main content

rig_core/providers/openai/responses_api/
mod.rs

1//! The OpenAI Responses API.
2//!
3//! By default when creating a completion client, this is the API that gets used.
4//!
5//! If you'd like to switch back to the regular Completions API, you can do so by using the `.completions_api()` function - see below for an example:
6//! ```rust
7//! use rig_core::client::{CompletionClient, ProviderClient};
8//!
9//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
10//! let openai_client = rig_core::providers::openai::Client::from_env()?;
11//! let model = openai_client.completion_model("gpt-4o").completions_api();
12//! # let _ = model;
13//! # Ok(())
14//! # }
15//! ```
16use super::InputAudio;
17use super::responses_api::streaming::StreamingCompletionResponse;
18use crate::completion::{CompletionError, GetTokenUsage};
19use crate::http_client;
20use crate::http_client::HttpClientExt;
21use crate::json_utils;
22use crate::message::{
23    AudioMediaType, Document, DocumentMediaType, DocumentSourceKind, ImageDetail, MessageError,
24    MimeType, Text,
25};
26use crate::one_or_many::string_or_one_or_many;
27use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
28
29use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
30use crate::{OneOrMany, completion, message};
31use serde::{Deserialize, Deserializer, Serialize, Serializer};
32use serde_json::{Map, Value};
33use tracing::{Instrument, Level, enabled};
34
35use std::convert::Infallible;
36use std::ops::Add;
37use std::str::FromStr;
38
39pub mod streaming;
40#[cfg(all(not(target_family = "wasm"), feature = "websocket"))]
41pub mod websocket;
42
43/// The completion request type for OpenAI's Response API: <https://platform.openai.com/docs/api-reference/responses/create>
44/// Intended to be derived from [`crate::completion::request::CompletionRequest`].
45#[derive(Debug, Deserialize, Serialize, Clone)]
46pub struct CompletionRequest {
47    /// Message inputs
48    pub input: OneOrMany<InputItem>,
49    /// The model name
50    pub model: String,
51    /// Instructions (also referred to as preamble, although in other APIs this would be the "system prompt")
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub instructions: Option<String>,
54    /// The maximum number of output tokens.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub max_output_tokens: Option<u64>,
57    /// Toggle to true for streaming responses.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub stream: Option<bool>,
60    /// The temperature. Set higher (up to a max of 1.0) for more creative responses.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub temperature: Option<f64>,
63    /// Whether the LLM should be forced to use a tool before returning a response.
64    /// If none provided, the default option is "auto".
65    #[serde(skip_serializing_if = "Option::is_none")]
66    tool_choice: Option<ToolChoice>,
67    /// The tools you want to use. This supports both function tools and hosted tools
68    /// such as `web_search`, `file_search`, and `computer_use`.
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub tools: Vec<ResponsesToolDefinition>,
71    /// Additional parameters
72    #[serde(flatten)]
73    pub additional_parameters: AdditionalParameters,
74}
75
76impl CompletionRequest {
77    pub fn with_structured_outputs<S>(mut self, schema_name: S, schema: serde_json::Value) -> Self
78    where
79        S: Into<String>,
80    {
81        self.additional_parameters.text = Some(TextConfig::structured_output(schema_name, schema));
82
83        self
84    }
85
86    pub fn with_reasoning(mut self, reasoning: Reasoning) -> Self {
87        self.additional_parameters.reasoning = Some(reasoning);
88
89        self
90    }
91
92    /// Adds a provider-native hosted tool (e.g. `web_search`, `file_search`, `computer_use`)
93    /// to the request. These tools are executed by OpenAI's infrastructure, not by Rig's
94    /// agent loop.
95    pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
96        self.tools.push(tool.into());
97        self
98    }
99
100    /// Adds multiple provider-native hosted tools to the request. These tools are executed
101    /// by OpenAI's infrastructure, not by Rig's agent loop.
102    pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
103    where
104        I: IntoIterator<Item = Tool>,
105        Tool: Into<ResponsesToolDefinition>,
106    {
107        self.tools.extend(tools.into_iter().map(Into::into));
108        self
109    }
110}
111
112/// An input item for [`CompletionRequest`].
113#[derive(Debug, Deserialize, Clone)]
114pub struct InputItem {
115    /// The role of an input item/message.
116    /// Input messages should be Some(Role::User), and output messages should be Some(Role::Assistant).
117    /// Everything else should be None.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    role: Option<Role>,
120    /// The input content itself.
121    #[serde(flatten)]
122    input: InputContent,
123}
124
125impl Serialize for InputItem {
126    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127    where
128        S: serde::Serializer,
129    {
130        let mut value = serde_json::to_value(&self.input).map_err(serde::ser::Error::custom)?;
131        let map = value.as_object_mut().ok_or_else(|| {
132            serde::ser::Error::custom("Input content must serialize to an object")
133        })?;
134
135        if let Some(role) = &self.role
136            && !map.contains_key("role")
137        {
138            map.insert(
139                "role".to_string(),
140                serde_json::to_value(role).map_err(serde::ser::Error::custom)?,
141            );
142        }
143
144        value.serialize(serializer)
145    }
146}
147
148impl InputItem {
149    pub fn system_message(content: impl Into<String>) -> Self {
150        Self {
151            role: Some(Role::System),
152            input: InputContent::Message(Message::System {
153                content: OneOrMany::one(SystemContent::InputText {
154                    text: content.into(),
155                }),
156                name: None,
157            }),
158        }
159    }
160
161    pub(crate) fn system_text(&self) -> Option<String> {
162        match &self.input {
163            InputContent::Message(Message::System { content, .. }) => Some(
164                content
165                    .iter()
166                    .map(|item| match item {
167                        SystemContent::InputText { text } => text.as_str(),
168                    })
169                    .collect::<Vec<_>>()
170                    .join("\n"),
171            ),
172            _ => None,
173        }
174    }
175}
176
177/// Message roles. Used by OpenAI Responses API to determine who created a given message.
178#[derive(Debug, Deserialize, Serialize, Clone)]
179#[serde(rename_all = "lowercase")]
180pub enum Role {
181    User,
182    Assistant,
183    System,
184}
185
186/// The type of content used in an [`InputItem`]. Additionally holds data for each type of input content.
187#[derive(Debug, Deserialize, Serialize, Clone)]
188#[serde(tag = "type", rename_all = "snake_case")]
189pub enum InputContent {
190    Message(Message),
191    Reasoning(OpenAIReasoning),
192    FunctionCall(OutputFunctionCall),
193    FunctionCallOutput(ToolResult),
194}
195
196#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
197pub struct OpenAIReasoning {
198    id: String,
199    pub summary: Vec<ReasoningSummary>,
200    #[serde(
201        default,
202        deserialize_with = "deserialize_reasoning_text_content",
203        serialize_with = "serialize_reasoning_text_content",
204        skip_serializing_if = "Vec::is_empty"
205    )]
206    pub content: Vec<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub encrypted_content: Option<String>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub status: Option<ToolStatus>,
211}
212
213#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum ReasoningSummary {
216    SummaryText { text: String },
217}
218
219impl ReasoningSummary {
220    fn new(input: &str) -> Self {
221        Self::SummaryText {
222            text: input.to_string(),
223        }
224    }
225
226    pub fn text(&self) -> String {
227        let ReasoningSummary::SummaryText { text } = self;
228        text.clone()
229    }
230}
231
232fn reasoning_text_content_json(content: &[String]) -> Value {
233    Value::Array(
234        content
235            .iter()
236            .map(|text| {
237                serde_json::json!({
238                    "type": "reasoning_text",
239                    "text": text,
240                })
241            })
242            .collect(),
243    )
244}
245
246fn serialize_reasoning_text_content<S>(content: &[String], serializer: S) -> Result<S::Ok, S::Error>
247where
248    S: Serializer,
249{
250    reasoning_text_content_json(content).serialize(serializer)
251}
252
253fn deserialize_reasoning_text_content<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
254where
255    D: Deserializer<'de>,
256{
257    let value = Value::deserialize(deserializer)?;
258    Ok(match value {
259        Value::Array(items) => items
260            .into_iter()
261            .filter_map(|item| match item {
262                Value::Object(mut item) => item
263                    .remove("text")
264                    .and_then(|text| text.as_str().map(ToOwned::to_owned)),
265                Value::String(text) => Some(text),
266                _ => None,
267            })
268            .collect(),
269        Value::String(text) => vec![text],
270        _ => Vec::new(),
271    })
272}
273
274/// A tool result.
275#[derive(Debug, Deserialize, Serialize, Clone)]
276pub struct ToolResult {
277    /// The call ID of a tool (this should be linked to the call ID for a tool call, otherwise an error will be received)
278    call_id: String,
279    /// The result of a tool call.
280    output: ToolResultOutput,
281    /// The status of a tool call (if used in a completion request, this should always be Completed)
282    status: ToolStatus,
283}
284
285/// Responses API function-call output, which accepts either plain text or an
286/// ordered list of rich input blocks.
287#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
288#[serde(untagged)]
289pub enum ToolResultOutput {
290    /// A plain textual function result.
291    Text(String),
292    /// Ordered rich input blocks for a multimodal function result.
293    Content(Vec<ToolResultOutputContent>),
294}
295
296/// Rich content supported by a Responses API function-call output.
297#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
298#[serde(tag = "type", rename_all = "snake_case")]
299pub enum ToolResultOutputContent {
300    /// Textual function-output content.
301    InputText {
302        /// The text presented to the model.
303        text: String,
304    },
305    /// Image function-output content.
306    InputImage {
307        /// A public URL or base64 data URL, mutually exclusive with `file_id`.
308        #[serde(skip_serializing_if = "Option::is_none")]
309        image_url: Option<String>,
310        /// An uploaded OpenAI file identifier, mutually exclusive with
311        /// `image_url`.
312        #[serde(skip_serializing_if = "Option::is_none")]
313        file_id: Option<String>,
314        /// Provider image-detail preference.
315        #[serde(default)]
316        detail: ImageDetail,
317    },
318}
319
320fn responses_tool_result_output(
321    content: OneOrMany<message::ToolResultContent>,
322) -> Result<ToolResultOutput, MessageError> {
323    let mut rich_output = Vec::new();
324
325    for content in content {
326        match content {
327            message::ToolResultContent::Text(Text { text, .. }) => {
328                rich_output.push(ToolResultOutputContent::InputText { text });
329            }
330            message::ToolResultContent::Json { value } => {
331                rich_output.push(ToolResultOutputContent::InputText {
332                    text: value.to_string(),
333                });
334            }
335            message::ToolResultContent::Image(message::Image {
336                data,
337                media_type,
338                detail,
339                ..
340            }) => {
341                let (image_url, file_id) = match data {
342                    DocumentSourceKind::Base64(data) => {
343                        let media_type = media_type.ok_or_else(|| {
344                            MessageError::ConversionError(
345                                "A media type is required for base64 tool-result images".into(),
346                            )
347                        })?;
348                        (
349                            Some(format!(
350                                "data:{media_type};base64,{data}",
351                                media_type = media_type.to_mime_type()
352                            )),
353                            None,
354                        )
355                    }
356                    DocumentSourceKind::Url(url) => (Some(url), None),
357                    DocumentSourceKind::FileId(file_id) => (None, Some(file_id)),
358                    unsupported => {
359                        return Err(MessageError::ConversionError(format!(
360                            "Unsupported tool-result image source: {unsupported}"
361                        )));
362                    }
363                };
364                rich_output.push(ToolResultOutputContent::InputImage {
365                    image_url,
366                    file_id,
367                    detail: detail.unwrap_or_default(),
368                });
369            }
370        }
371    }
372
373    match rich_output.as_slice() {
374        [ToolResultOutputContent::InputText { text }] => Ok(ToolResultOutput::Text(text.clone())),
375
376        _ => Ok(ToolResultOutput::Content(rich_output)),
377    }
378}
379
380impl From<Message> for InputItem {
381    fn from(value: Message) -> Self {
382        match value {
383            Message::User { .. } => Self {
384                role: Some(Role::User),
385                input: InputContent::Message(value),
386            },
387            Message::Assistant { ref content, .. } => {
388                let role = if content
389                    .iter()
390                    .any(|x| matches!(x, AssistantContentType::Reasoning(_)))
391                {
392                    None
393                } else {
394                    Some(Role::Assistant)
395                };
396                Self {
397                    role,
398                    input: InputContent::Message(value),
399                }
400            }
401            Message::AssistantInput { .. } => Self {
402                role: Some(Role::Assistant),
403                input: InputContent::Message(value),
404            },
405            Message::System { .. } => Self {
406                role: Some(Role::System),
407                input: InputContent::Message(value),
408            },
409            Message::ToolResult {
410                tool_call_id,
411                output,
412            } => Self {
413                role: None,
414                input: InputContent::FunctionCallOutput(ToolResult {
415                    call_id: tool_call_id,
416                    output,
417                    status: ToolStatus::Completed,
418                }),
419            },
420        }
421    }
422}
423
424impl TryFrom<crate::completion::Message> for Vec<InputItem> {
425    type Error = CompletionError;
426
427    fn try_from(value: crate::completion::Message) -> Result<Self, Self::Error> {
428        match value {
429            crate::completion::Message::System { content } => Ok(vec![InputItem {
430                role: Some(Role::System),
431                input: InputContent::Message(Message::System {
432                    content: OneOrMany::one(content.into()),
433                    name: None,
434                }),
435            }]),
436            crate::completion::Message::User { content } => {
437                let mut items = Vec::new();
438
439                for user_content in content {
440                    match user_content {
441                        crate::message::UserContent::Text(Text { text, .. }) => {
442                            items.push(InputItem {
443                                role: Some(Role::User),
444                                input: InputContent::Message(Message::User {
445                                    content: OneOrMany::one(UserContent::InputText { text }),
446                                    name: None,
447                                }),
448                            });
449                        }
450                        crate::message::UserContent::ToolResult(
451                            crate::completion::message::ToolResult {
452                                call_id,
453                                content: tool_content,
454                                ..
455                            },
456                        ) => {
457                            let output =
458                                responses_tool_result_output(tool_content).map_err(|error| {
459                                    CompletionError::ProviderError(error.to_string())
460                                })?;
461                            items.push(InputItem {
462                                role: None,
463                                input: InputContent::FunctionCallOutput(ToolResult {
464                                    call_id: require_call_id(call_id, "Tool result")?,
465                                    output,
466                                    status: ToolStatus::Completed,
467                                }),
468                            });
469                        }
470                        crate::message::UserContent::Document(Document {
471                            data: DocumentSourceKind::FileId(file_id),
472                            ..
473                        }) => items.push(InputItem {
474                            role: Some(Role::User),
475                            input: InputContent::Message(Message::User {
476                                content: OneOrMany::one(UserContent::InputFile {
477                                    file_id: Some(file_id),
478                                    file_data: None,
479                                    file_url: None,
480                                    filename: None,
481                                }),
482                                name: None,
483                            }),
484                        }),
485                        crate::message::UserContent::Document(Document {
486                            data,
487                            media_type: Some(DocumentMediaType::PDF),
488                            ..
489                        }) => {
490                            let (file_data, file_url, filename) = match data {
491                                DocumentSourceKind::Base64(data) => (
492                                    Some(format!("data:application/pdf;base64,{data}")),
493                                    None,
494                                    Some("document.pdf".to_string()),
495                                ),
496                                DocumentSourceKind::Url(url) => (None, Some(url), None),
497                                DocumentSourceKind::Raw(_) => {
498                                    return Err(CompletionError::RequestError(
499                                        "Raw file data not supported, encode as base64 first"
500                                            .into(),
501                                    ));
502                                }
503                                doc => {
504                                    return Err(CompletionError::RequestError(
505                                        format!("Unsupported document type: {doc}").into(),
506                                    ));
507                                }
508                            };
509
510                            items.push(InputItem {
511                                role: Some(Role::User),
512                                input: InputContent::Message(Message::User {
513                                    content: OneOrMany::one(UserContent::InputFile {
514                                        file_id: None,
515                                        file_data,
516                                        file_url,
517                                        filename,
518                                    }),
519                                    name: None,
520                                }),
521                            })
522                        }
523                        crate::message::UserContent::Document(Document {
524                            data:
525                                DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text),
526                            ..
527                        }) => items.push(InputItem {
528                            role: Some(Role::User),
529                            input: InputContent::Message(Message::User {
530                                content: OneOrMany::one(UserContent::InputText { text }),
531                                name: None,
532                            }),
533                        }),
534                        crate::message::UserContent::Image(crate::message::Image {
535                            data,
536                            media_type,
537                            detail,
538                            ..
539                        }) => {
540                            let url = match data {
541                                DocumentSourceKind::Base64(data) => {
542                                    let media_type = if let Some(media_type) = media_type {
543                                        media_type.to_mime_type().to_string()
544                                    } else {
545                                        String::new()
546                                    };
547                                    format!("data:{media_type};base64,{data}")
548                                }
549                                DocumentSourceKind::Url(url) => url,
550                                DocumentSourceKind::Raw(_) => {
551                                    return Err(CompletionError::RequestError(
552                                        "Raw file data not supported, encode as base64 first"
553                                            .into(),
554                                    ));
555                                }
556                                doc => {
557                                    return Err(CompletionError::RequestError(
558                                        format!("Unsupported document type: {doc}").into(),
559                                    ));
560                                }
561                            };
562                            items.push(InputItem {
563                                role: Some(Role::User),
564                                input: InputContent::Message(Message::User {
565                                    content: OneOrMany::one(UserContent::InputImage {
566                                        image_url: url,
567                                        detail: detail.unwrap_or_default(),
568                                    }),
569                                    name: None,
570                                }),
571                            });
572                        }
573                        message => {
574                            return Err(CompletionError::ProviderError(format!(
575                                "Unsupported message: {message:?}"
576                            )));
577                        }
578                    }
579                }
580
581                Ok(items)
582            }
583            crate::completion::Message::Assistant { id, content } => {
584                let mut reasoning_items = Vec::new();
585                let mut other_items = Vec::new();
586
587                for assistant_content in content {
588                    match assistant_content {
589                        crate::message::AssistantContent::Text(Text { text, .. }) => {
590                            if text.is_empty() {
591                                continue;
592                            }
593                            let message = if let Some(id) = id.clone() {
594                                Message::Assistant {
595                                    content: OneOrMany::one(AssistantContentType::Text(
596                                        AssistantContent::OutputText(Text::new(text)),
597                                    )),
598                                    id,
599                                    name: None,
600                                    status: ToolStatus::Completed,
601                                }
602                            } else {
603                                Message::AssistantInput {
604                                    content: text,
605                                    name: None,
606                                }
607                            };
608
609                            other_items.push(InputItem {
610                                role: Some(Role::Assistant),
611                                input: InputContent::Message(message),
612                            });
613                        }
614                        crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
615                            id: tool_id,
616                            call_id,
617                            function,
618                            ..
619                        }) => {
620                            other_items.push(InputItem {
621                                role: None,
622                                input: InputContent::FunctionCall(OutputFunctionCall {
623                                    arguments: function.arguments,
624                                    call_id: require_call_id(call_id, "Assistant tool call")?,
625                                    id: tool_id,
626                                    name: function.name,
627                                    status: ToolStatus::Completed,
628                                }),
629                            });
630                        }
631                        crate::message::AssistantContent::Reasoning(reasoning) => {
632                            let openai_reasoning = openai_reasoning_from_core(&reasoning)
633                                .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
634                            if let Some(openai_reasoning) = openai_reasoning {
635                                reasoning_items.push(InputItem {
636                                    role: None,
637                                    input: InputContent::Reasoning(openai_reasoning),
638                                });
639                            }
640                        }
641                        crate::message::AssistantContent::Image(_) => {
642                            return Err(CompletionError::ProviderError(
643                                "Assistant image content is not supported in OpenAI Responses API"
644                                    .to_string(),
645                            ));
646                        }
647                    }
648                }
649
650                let mut items = reasoning_items;
651                items.extend(other_items);
652                Ok(items)
653            }
654        }
655    }
656}
657
658impl From<OneOrMany<String>> for Vec<ReasoningSummary> {
659    fn from(value: OneOrMany<String>) -> Self {
660        value.iter().map(|x| ReasoningSummary::new(x)).collect()
661    }
662}
663
664fn require_call_id(call_id: Option<String>, context: &str) -> Result<String, CompletionError> {
665    call_id.ok_or_else(|| {
666        CompletionError::RequestError(
667            format!("{context} `call_id` is required for OpenAI Responses API").into(),
668        )
669    })
670}
671
672fn openai_reasoning_from_core(
673    reasoning: &crate::message::Reasoning,
674) -> Result<Option<OpenAIReasoning>, MessageError> {
675    let Some(id) = reasoning.id.clone() else {
676        return Ok(None);
677    };
678
679    let mut summary = Vec::new();
680    let mut reasoning_content = Vec::new();
681    let mut encrypted_content = None;
682    for content in &reasoning.content {
683        match content {
684            crate::message::ReasoningContent::Text { text, .. } => {
685                reasoning_content.push(text.clone());
686            }
687            crate::message::ReasoningContent::Summary(text) => {
688                summary.push(ReasoningSummary::new(text));
689            }
690            // OpenAI reasoning input has one opaque payload field; preserve either
691            // encrypted or redacted blocks there, preferring the first one seen.
692            crate::message::ReasoningContent::Encrypted(data)
693            | crate::message::ReasoningContent::Redacted { data } => {
694                encrypted_content.get_or_insert_with(|| data.clone());
695            }
696        }
697    }
698
699    Ok(Some(OpenAIReasoning {
700        id,
701        summary,
702        content: reasoning_content,
703        encrypted_content,
704        status: None,
705    }))
706}
707
708/// The definition of a tool response, repurposed for OpenAI's Responses API.
709#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
710pub struct ResponsesToolDefinition {
711    /// The type of tool.
712    #[serde(rename = "type")]
713    pub kind: String,
714    /// Tool name
715    #[serde(default, skip_serializing_if = "String::is_empty")]
716    pub name: String,
717    /// Parameters - this should be a JSON schema. Strict function tools must use OpenAI's supported strict schema subset.
718    #[serde(default, skip_serializing_if = "is_json_null")]
719    pub parameters: serde_json::Value,
720    /// Whether to use strict mode. Disabled by default; opt in with [`Self::with_strict`]
721    /// or [`GenericResponsesCompletionModel::with_strict_tools`].
722    #[serde(
723        default,
724        skip_serializing_if = "is_false",
725        deserialize_with = "json_utils::null_or_default"
726    )]
727    pub strict: bool,
728    /// Tool description.
729    #[serde(default, skip_serializing_if = "String::is_empty")]
730    pub description: String,
731    /// Additional provider-specific configuration for hosted tools.
732    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
733    pub config: Map<String, Value>,
734}
735
736fn is_json_null(value: &Value) -> bool {
737    value.is_null()
738}
739
740fn is_false(value: &bool) -> bool {
741    !value
742}
743
744impl ResponsesToolDefinition {
745    /// Creates a function tool definition with strict mode disabled.
746    pub fn function(
747        name: impl Into<String>,
748        description: impl Into<String>,
749        parameters: serde_json::Value,
750    ) -> Self {
751        Self {
752            kind: "function".to_string(),
753            name: name.into(),
754            parameters,
755            strict: false,
756            description: description.into(),
757            config: Map::new(),
758        }
759    }
760
761    /// Creates a strict function tool definition.
762    ///
763    /// The schema is sanitized to OpenAI's strict subset (`additionalProperties: false`
764    /// added and every property forced into `required`).
765    pub fn strict_function(
766        name: impl Into<String>,
767        description: impl Into<String>,
768        parameters: serde_json::Value,
769    ) -> Self {
770        Self::function(name, description, parameters).with_strict()
771    }
772
773    /// Enables strict mode for this function tool.
774    ///
775    /// Function schemas are sanitized to OpenAI's strict subset. Hosted tools are
776    /// returned unchanged because strict mode only applies to function tools.
777    pub fn with_strict(mut self) -> Self {
778        if self.kind == "function" {
779            super::sanitize_schema(&mut self.parameters);
780            self.strict = true;
781        }
782        self
783    }
784
785    /// Creates a hosted tool definition for an arbitrary hosted tool type.
786    pub fn hosted(kind: impl Into<String>) -> Self {
787        Self {
788            kind: kind.into(),
789            name: String::new(),
790            parameters: Value::Null,
791            strict: false,
792            description: String::new(),
793            config: Map::new(),
794        }
795    }
796
797    /// Creates a hosted `web_search` tool definition.
798    pub fn web_search() -> Self {
799        Self::hosted("web_search")
800    }
801
802    /// Creates a hosted `file_search` tool definition.
803    pub fn file_search() -> Self {
804        Self::hosted("file_search")
805    }
806
807    /// Creates a hosted `computer_use` tool definition.
808    pub fn computer_use() -> Self {
809        Self::hosted("computer_use")
810    }
811
812    /// Adds hosted-tool configuration fields.
813    pub fn with_config(mut self, key: impl Into<String>, value: Value) -> Self {
814        self.config.insert(key.into(), value);
815        self
816    }
817
818    fn normalize(self) -> Self {
819        self.with_strict()
820    }
821}
822
823impl From<completion::ToolDefinition> for ResponsesToolDefinition {
824    fn from(value: completion::ToolDefinition) -> Self {
825        let completion::ToolDefinition {
826            name,
827            parameters,
828            description,
829        } = value;
830
831        Self::function(name, description, parameters)
832    }
833}
834
835/// Tool choice for the OpenAI Responses API.
836///
837/// The Responses API accepts the `"auto"`/`"none"`/`"required"` modes shared
838/// with the Chat Completions API, and additionally supports forcing one
839/// specific function (`{"type": "function", "name": "..."}`) or restricting
840/// the model to a subset of the request's tools
841/// (`{"type": "allowed_tools", ...}`).
842#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
843#[serde(untagged)]
844pub enum ToolChoice {
845    /// `"auto"`, `"none"`, or `"required"`. The wrapped chat-completions
846    /// enum also has a `Function` variant whose nested wire shape the
847    /// Responses API rejects — use [`ToolChoiceDefinition::Function`] to
848    /// force a function here.
849    Mode(super::completion::ToolChoice),
850    /// A typed tool-choice object (`function` or `allowed_tools`).
851    Definition(ToolChoiceDefinition),
852}
853
854/// A typed Responses API tool-choice object.
855#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
856#[serde(tag = "type", rename_all = "snake_case")]
857pub enum ToolChoiceDefinition {
858    /// Force the model to call the named function tool.
859    Function {
860        /// Name of the function tool the model must call.
861        name: String,
862    },
863    /// Restrict the model to a subset of the request's tools.
864    AllowedTools {
865        /// Whether the model may still answer without a tool call (`auto`)
866        /// or must call one of the allowed tools (`required`).
867        mode: AllowedToolsMode,
868        /// The tools the model is allowed to call.
869        tools: Vec<AllowedTool>,
870    },
871}
872
873/// Constrains how the model may use the tools listed in
874/// [`ToolChoiceDefinition::AllowedTools`].
875#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
876#[serde(rename_all = "snake_case")]
877pub enum AllowedToolsMode {
878    /// The model may call one of the allowed tools or answer directly.
879    Auto,
880    /// The model must call one of the allowed tools.
881    Required,
882}
883
884/// One entry of a [`ToolChoiceDefinition::AllowedTools`] tool list.
885#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
886#[serde(tag = "type", rename_all = "snake_case")]
887pub enum AllowedTool {
888    /// A function tool referenced by name.
889    Function {
890        /// Name of the allowed function tool.
891        name: String,
892    },
893}
894
895impl TryFrom<message::ToolChoice> for ToolChoice {
896    type Error = CompletionError;
897
898    fn try_from(value: message::ToolChoice) -> Result<Self, Self::Error> {
899        let choice = match value {
900            message::ToolChoice::Auto => Self::Mode(super::completion::ToolChoice::Auto),
901            message::ToolChoice::None => Self::Mode(super::completion::ToolChoice::None),
902            message::ToolChoice::Required => Self::Mode(super::completion::ToolChoice::Required),
903            message::ToolChoice::Specific { function_names } => {
904                let mut names = function_names.into_iter();
905                let Some(first) = names.next() else {
906                    return Err(CompletionError::RequestError(
907                        "ToolChoice::Specific requires at least one function name".into(),
908                    ));
909                };
910
911                match names.next() {
912                    None => Self::Definition(ToolChoiceDefinition::Function { name: first }),
913                    Some(second) => {
914                        let tools = std::iter::once(first)
915                            .chain(std::iter::once(second))
916                            .chain(names)
917                            .map(|name| AllowedTool::Function { name })
918                            .collect();
919                        Self::Definition(ToolChoiceDefinition::AllowedTools {
920                            mode: AllowedToolsMode::Required,
921                            tools,
922                        })
923                    }
924                }
925            }
926        };
927
928        Ok(choice)
929    }
930}
931
932/// Token usage.
933/// Token usage from the OpenAI Responses API generally shows the input tokens and output tokens (both with more in-depth details) as well as a total tokens field.
934#[derive(Clone, Debug, Serialize, Deserialize)]
935pub struct ResponsesUsage {
936    /// Input tokens
937    pub input_tokens: u64,
938    /// In-depth detail on input tokens (cached tokens)
939    #[serde(skip_serializing_if = "Option::is_none")]
940    pub input_tokens_details: Option<InputTokensDetails>,
941    /// Output tokens
942    pub output_tokens: u64,
943    /// In-depth detail on output tokens (reasoning tokens)
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub output_tokens_details: Option<OutputTokensDetails>,
946    /// Total tokens used (for a given prompt)
947    pub total_tokens: u64,
948}
949
950impl ResponsesUsage {
951    /// Create a new ResponsesUsage instance
952    pub(crate) fn new() -> Self {
953        Self {
954            input_tokens: 0,
955            input_tokens_details: Some(InputTokensDetails::new()),
956            output_tokens: 0,
957            output_tokens_details: Some(OutputTokensDetails::new()),
958            total_tokens: 0,
959        }
960    }
961}
962
963impl GetTokenUsage for ResponsesUsage {
964    fn token_usage(&self) -> crate::completion::Usage {
965        crate::completion::Usage {
966            input_tokens: self.input_tokens,
967            output_tokens: self.output_tokens,
968            total_tokens: self.total_tokens,
969            cached_input_tokens: self
970                .input_tokens_details
971                .as_ref()
972                .map(|details| details.cached_tokens)
973                .unwrap_or(0),
974            cache_creation_input_tokens: 0,
975            tool_use_prompt_tokens: 0,
976            reasoning_tokens: self
977                .output_tokens_details
978                .as_ref()
979                .map(|details| details.reasoning_tokens)
980                .unwrap_or(0),
981        }
982    }
983}
984
985impl Add for ResponsesUsage {
986    type Output = Self;
987
988    fn add(self, rhs: Self) -> Self::Output {
989        let input_tokens = self.input_tokens + rhs.input_tokens;
990        let input_tokens_details = match (self.input_tokens_details, rhs.input_tokens_details) {
991            (Some(lhs), Some(rhs)) => Some(lhs + rhs),
992            (Some(lhs), None) => Some(lhs),
993            (None, Some(rhs)) => Some(rhs),
994            (None, None) => None,
995        };
996        let output_tokens = self.output_tokens + rhs.output_tokens;
997        let output_tokens_details = match (self.output_tokens_details, rhs.output_tokens_details) {
998            (Some(lhs), Some(rhs)) => Some(lhs + rhs),
999            (Some(lhs), None) => Some(lhs),
1000            (None, Some(rhs)) => Some(rhs),
1001            (None, None) => None,
1002        };
1003        let total_tokens = self.total_tokens + rhs.total_tokens;
1004        Self {
1005            input_tokens,
1006            input_tokens_details,
1007            output_tokens,
1008            output_tokens_details,
1009            total_tokens,
1010        }
1011    }
1012}
1013
1014/// In-depth details on input tokens.
1015#[derive(Clone, Debug, Serialize, Deserialize)]
1016pub struct InputTokensDetails {
1017    /// Cached tokens from OpenAI
1018    pub cached_tokens: u64,
1019}
1020
1021impl InputTokensDetails {
1022    pub(crate) fn new() -> Self {
1023        Self { cached_tokens: 0 }
1024    }
1025}
1026
1027impl Add for InputTokensDetails {
1028    type Output = Self;
1029    fn add(self, rhs: Self) -> Self::Output {
1030        Self {
1031            cached_tokens: self.cached_tokens + rhs.cached_tokens,
1032        }
1033    }
1034}
1035
1036/// In-depth details on output tokens.
1037#[derive(Clone, Debug, Serialize, Deserialize)]
1038pub struct OutputTokensDetails {
1039    /// Reasoning tokens
1040    pub reasoning_tokens: u64,
1041}
1042
1043impl OutputTokensDetails {
1044    pub(crate) fn new() -> Self {
1045        Self {
1046            reasoning_tokens: 0,
1047        }
1048    }
1049}
1050
1051impl Add for OutputTokensDetails {
1052    type Output = Self;
1053    fn add(self, rhs: Self) -> Self::Output {
1054        Self {
1055            reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
1056        }
1057    }
1058}
1059
1060/// Occasionally, when using OpenAI's Responses API you may get an incomplete response. This struct holds the reason as to why it happened.
1061#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1062pub struct IncompleteDetailsReason {
1063    /// The reason for an incomplete [`CompletionResponse`].
1064    pub reason: String,
1065}
1066
1067/// A response error from OpenAI's Response API.
1068#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1069pub struct ResponseError {
1070    /// Error code
1071    pub code: String,
1072    /// Error message
1073    pub message: String,
1074}
1075
1076/// A response object as an enum (ensures type validation)
1077#[derive(Clone, Debug, Deserialize, Serialize)]
1078#[serde(rename_all = "snake_case")]
1079pub enum ResponseObject {
1080    Response,
1081}
1082
1083/// The response status as an enum (ensures type validation)
1084#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1085#[serde(rename_all = "snake_case")]
1086pub enum ResponseStatus {
1087    InProgress,
1088    Completed,
1089    Failed,
1090    Cancelled,
1091    Queued,
1092    Incomplete,
1093}
1094
1095/// Controls where Rig system instructions are placed in an OpenAI Responses request.
1096#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1097#[non_exhaustive]
1098pub enum SystemInstructionsPlacement {
1099    /// Send the leading run of system instructions (the preamble and any system
1100    /// messages that open the conversation) through the official top-level
1101    /// `instructions` field. Mid-conversation system messages keep their
1102    /// position in `input`.
1103    #[default]
1104    Instructions,
1105    /// Send every system message through the top-level `instructions` field,
1106    /// including mid-conversation ones.
1107    ///
1108    /// Use this for backends that reject the `system` role in `input` entirely.
1109    AllInstructions,
1110    /// Send system instructions as `system` messages in `input`.
1111    ///
1112    /// Use this only for OpenAI-compatible providers that do not support top-level
1113    /// `instructions`.
1114    InputSystemMessages,
1115}
1116
1117/// Provider extensions that drive the OpenAI Responses request conversion.
1118///
1119/// Implemented by the `Ext` type of a [`crate::client::Client`] used with
1120/// [`GenericResponsesCompletionModel`], so a client-level configuration can
1121/// control request shaping for every model created from that client.
1122pub trait ResponsesProviderExt {
1123    /// Where Rig system instructions are placed in requests built from this
1124    /// provider. See [`SystemInstructionsPlacement`].
1125    ///
1126    /// Deliberately has no default body: each provider must state its
1127    /// placement explicitly, so a backend that can't handle the default
1128    /// (top-level `instructions`) is never inherited by accident.
1129    fn system_instructions_placement(&self) -> SystemInstructionsPlacement;
1130}
1131
1132/// Attempt to try and create a `NewCompletionRequest` from a model name and [`crate::completion::CompletionRequest`]
1133impl TryFrom<(String, crate::completion::CompletionRequest)> for CompletionRequest {
1134    type Error = CompletionError;
1135    fn try_from(
1136        (model, request): (String, crate::completion::CompletionRequest),
1137    ) -> Result<Self, Self::Error> {
1138        Self::try_from(ResponsesRequestParams {
1139            model,
1140            request,
1141            system_instructions_placement: SystemInstructionsPlacement::default(),
1142        })
1143    }
1144}
1145
1146/// Parameters for converting a [`crate::completion::CompletionRequest`] into a
1147/// Responses API [`CompletionRequest`] with a non-default configuration.
1148pub struct ResponsesRequestParams {
1149    pub model: String,
1150    pub request: crate::completion::CompletionRequest,
1151    pub system_instructions_placement: SystemInstructionsPlacement,
1152}
1153
1154impl TryFrom<ResponsesRequestParams> for CompletionRequest {
1155    type Error = CompletionError;
1156
1157    fn try_from(params: ResponsesRequestParams) -> Result<Self, Self::Error> {
1158        let ResponsesRequestParams {
1159            model,
1160            request: mut req,
1161            system_instructions_placement,
1162        } = params;
1163        let chat_history = req.chat_history_with_documents();
1164        let model = req.model.clone().unwrap_or(model);
1165        let preamble = req.preamble.take();
1166        let mut instruction_parts = Vec::new();
1167        let mut input = {
1168            let mut partial_history = vec![];
1169            partial_history.extend(chat_history);
1170
1171            let mut full_history: Vec<InputItem> = preamble
1172                .map(InputItem::system_message)
1173                .into_iter()
1174                .collect();
1175
1176            for history_item in partial_history {
1177                full_history.extend(<Vec<InputItem>>::try_from(history_item)?);
1178            }
1179
1180            full_history
1181        };
1182
1183        let mut lift_system_text = |text: String| {
1184            let text = text.trim();
1185            if !text.is_empty() {
1186                instruction_parts.push(text.to_string());
1187            }
1188        };
1189        let items_before_lift = input.len();
1190        match system_instructions_placement {
1191            SystemInstructionsPlacement::Instructions => {
1192                // Lift only the leading run of system items (the preamble and any
1193                // system messages that open the conversation) into the top-level
1194                // `instructions` field. Mid-conversation system messages keep
1195                // their position in `input`, and a request made up solely of
1196                // system messages keeps them in `input` so it stays non-empty.
1197                let leading_system_texts: Vec<String> =
1198                    input.iter().map_while(InputItem::system_text).collect();
1199                if leading_system_texts.len() < input.len() {
1200                    input.drain(..leading_system_texts.len());
1201                    leading_system_texts
1202                        .into_iter()
1203                        .for_each(&mut lift_system_text);
1204                }
1205            }
1206            SystemInstructionsPlacement::AllInstructions => {
1207                // Lift every system item, wherever it appears, for backends
1208                // that reject the `system` role in `input` entirely.
1209                let mut remaining = Vec::with_capacity(input.len());
1210                for item in input {
1211                    match item.system_text() {
1212                        Some(text) => lift_system_text(text),
1213                        None => remaining.push(item),
1214                    }
1215                }
1216                input = remaining;
1217            }
1218            SystemInstructionsPlacement::InputSystemMessages => {}
1219        }
1220        let instructions = (!instruction_parts.is_empty()).then(|| instruction_parts.join("\n\n"));
1221        let lifted_system_items = input.len() < items_before_lift;
1222
1223        let input = OneOrMany::many(input).map_err(|_| {
1224            CompletionError::RequestError(if lifted_system_items {
1225                "OpenAI Responses request input must contain at least one non-system item \
1226                 (system messages were lifted into the top-level `instructions` field)"
1227                    .into()
1228            } else {
1229                "OpenAI Responses request input must contain at least one item".into()
1230            })
1231        })?;
1232
1233        let mut additional_params_payload = req.additional_params.take().unwrap_or(Value::Null);
1234        let stream = match &additional_params_payload {
1235            Value::Bool(stream) => Some(*stream),
1236            Value::Object(map) => map.get("stream").and_then(Value::as_bool),
1237            _ => None,
1238        };
1239
1240        let mut additional_tools = Vec::new();
1241        if let Some(additional_params_map) = additional_params_payload.as_object_mut() {
1242            if let Some(raw_tools) = additional_params_map.remove("tools") {
1243                additional_tools = serde_json::from_value::<Vec<ResponsesToolDefinition>>(
1244                    raw_tools,
1245                )
1246                .map_err(|err| {
1247                    CompletionError::RequestError(
1248                        format!(
1249                            "Invalid OpenAI Responses tools payload in additional_params: {err}"
1250                        )
1251                        .into(),
1252                    )
1253                })?;
1254            }
1255            additional_params_map.remove("stream");
1256        }
1257
1258        if additional_params_payload.is_boolean() {
1259            additional_params_payload = Value::Null;
1260        }
1261
1262        let mut additional_parameters = if additional_params_payload.is_null() {
1263            // If there's no additional parameters, initialise an empty object
1264            AdditionalParameters::default()
1265        } else {
1266            serde_json::from_value::<AdditionalParameters>(additional_params_payload).map_err(
1267                |err| {
1268                    CompletionError::RequestError(
1269                        format!("Invalid OpenAI Responses additional_params payload: {err}").into(),
1270                    )
1271                },
1272            )?
1273        };
1274        if additional_parameters.reasoning.is_some() {
1275            let include = additional_parameters.include.get_or_insert_with(Vec::new);
1276            if !include
1277                .iter()
1278                .any(|item| matches!(item, Include::ReasoningEncryptedContent))
1279            {
1280                include.push(Include::ReasoningEncryptedContent);
1281            }
1282        }
1283
1284        // Apply output_schema as structured output if not already configured via additional_params
1285        if additional_parameters.text.is_none()
1286            && let Some(schema) = req.output_schema
1287        {
1288            let name = schema
1289                .as_object()
1290                .and_then(|o| o.get("title"))
1291                .and_then(|v| v.as_str())
1292                .unwrap_or("response_schema")
1293                .to_string();
1294            let mut schema_value = schema.to_value();
1295            super::sanitize_schema(&mut schema_value);
1296            additional_parameters.text = Some(TextConfig::structured_output(name, schema_value));
1297        }
1298
1299        let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
1300        let mut tools: Vec<ResponsesToolDefinition> = req
1301            .tools
1302            .into_iter()
1303            .map(ResponsesToolDefinition::from)
1304            .collect();
1305        tools.append(&mut additional_tools);
1306
1307        Ok(Self {
1308            input,
1309            model,
1310            instructions,
1311            max_output_tokens: req.max_tokens,
1312            stream,
1313            tool_choice,
1314            tools,
1315            temperature: req.temperature,
1316            additional_parameters,
1317        })
1318    }
1319}
1320
1321/// The completion model struct for OpenAI's response API.
1322#[doc(hidden)]
1323#[derive(Clone)]
1324pub struct GenericResponsesCompletionModel<Ext = super::OpenAIResponsesExt, H = reqwest::Client> {
1325    /// The OpenAI client
1326    pub(crate) client: crate::client::Client<Ext, H>,
1327    /// Name of the model (e.g.: gpt-3.5-turbo-1106)
1328    pub model: String,
1329    /// Model-level default tools that are always added to outgoing requests.
1330    pub tools: Vec<ResponsesToolDefinition>,
1331    /// Whether function tools should use strict mode. Disabled by default to match
1332    /// the Chat Completions API; enable with [`Self::with_strict_tools`].
1333    pub strict_tools: bool,
1334    system_instructions_placement: SystemInstructionsPlacement,
1335}
1336
1337/// The completion model struct for OpenAI's Responses API.
1338///
1339/// This preserves the historical public generic shape where the first generic
1340/// parameter is the HTTP client type.
1341pub type ResponsesCompletionModel<H = reqwest::Client> =
1342    GenericResponsesCompletionModel<super::OpenAIResponsesExt, H>;
1343
1344impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
1345where
1346    crate::client::Client<Ext, H>: HttpClientExt + Clone + std::fmt::Debug + 'static,
1347    Ext: crate::client::Provider + ResponsesProviderExt + Clone + 'static,
1348    H: Clone + Default + std::fmt::Debug + 'static,
1349{
1350    /// Creates a new [`ResponsesCompletionModel`].
1351    pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1352        let system_instructions_placement = client.ext().system_instructions_placement();
1353        Self {
1354            client,
1355            model: model.into(),
1356            tools: Vec::new(),
1357            strict_tools: false,
1358            system_instructions_placement,
1359        }
1360    }
1361
1362    pub fn with_model(client: crate::client::Client<Ext, H>, model: &str) -> Self {
1363        Self::new(client, model)
1364    }
1365
1366    /// Enable strict mode for function tool schemas.
1367    ///
1368    /// When enabled, function tool schemas are sanitized to meet OpenAI's strict
1369    /// mode requirements and `strict: true` is set on each function definition.
1370    pub fn with_strict_tools(mut self) -> Self {
1371        self.strict_tools = true;
1372        self
1373    }
1374
1375    /// Sets where Rig system instructions are placed in requests from this
1376    /// model, overriding the client-level default. See
1377    /// [`SystemInstructionsPlacement`] for when each placement applies.
1378    pub fn with_system_instructions_placement(
1379        mut self,
1380        placement: SystemInstructionsPlacement,
1381    ) -> Self {
1382        self.system_instructions_placement = placement;
1383        self
1384    }
1385
1386    /// Sends Rig system instructions as `system` messages in `input` instead of
1387    /// as top-level Responses API `instructions`.
1388    ///
1389    /// OpenAI's Responses API supports `instructions`, and Rig uses it by
1390    /// default. Use this compatibility fallback for OpenAI-compatible providers
1391    /// that reject or ignore top-level `instructions`.
1392    pub fn with_system_instructions_as_messages(self) -> Self {
1393        self.with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
1394    }
1395
1396    /// Adds a default tool to all requests from this model.
1397    pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
1398        self.tools.push(tool.into());
1399        self
1400    }
1401
1402    /// Adds default tools to all requests from this model.
1403    pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
1404    where
1405        I: IntoIterator<Item = Tool>,
1406        Tool: Into<ResponsesToolDefinition>,
1407    {
1408        self.tools.extend(tools.into_iter().map(Into::into));
1409        self
1410    }
1411
1412    /// Attempt to create a completion request from [`crate::completion::CompletionRequest`].
1413    pub(crate) fn create_completion_request(
1414        &self,
1415        completion_request: crate::completion::CompletionRequest,
1416    ) -> Result<CompletionRequest, CompletionError> {
1417        let mut req = CompletionRequest::try_from(ResponsesRequestParams {
1418            model: self.model.clone(),
1419            request: completion_request,
1420            system_instructions_placement: self.system_instructions_placement,
1421        })?;
1422        req.tools.extend(self.tools.clone());
1423
1424        if self.strict_tools {
1425            req.tools = req
1426                .tools
1427                .into_iter()
1428                .map(ResponsesToolDefinition::normalize)
1429                .collect();
1430        }
1431
1432        Ok(req)
1433    }
1434}
1435
1436impl<T> GenericResponsesCompletionModel<super::OpenAIResponsesExt, T>
1437where
1438    T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
1439{
1440    /// Use the Completions API instead of Responses.
1441    pub fn completions_api(self) -> crate::providers::openai::completion::CompletionModel<T> {
1442        super::completion::CompletionModel::new(self.client.completions_api(), &self.model)
1443    }
1444}
1445
1446/// The standard response format from OpenAI's Responses API.
1447#[derive(Clone, Debug)]
1448pub struct CompletionResponse {
1449    /// The ID of a completion response.
1450    pub id: String,
1451    /// The type of the object.
1452    pub object: ResponseObject,
1453    /// The time at which a given response has been created, in seconds from the UNIX epoch (01/01/1970 00:00:00).
1454    pub created_at: u64,
1455    /// The status of the response.
1456    pub status: ResponseStatus,
1457    /// Response error (optional)
1458    pub error: Option<ResponseError>,
1459    /// Incomplete response details (optional)
1460    pub incomplete_details: Option<IncompleteDetailsReason>,
1461    /// System prompt/preamble
1462    pub instructions: Option<String>,
1463    /// The maximum number of tokens the model should output
1464    pub max_output_tokens: Option<u64>,
1465    /// The model name
1466    pub model: String,
1467    /// Provider-specific top-level reasoning content returned by some
1468    /// OpenAI-compatible Responses implementations.
1469    pub provider_reasoning: Option<String>,
1470    /// The complete object-shaped top-level reasoning metadata returned by the provider.
1471    ///
1472    /// Unknown fields, unknown values, and null-valued members inside the object
1473    /// are preserved value-equivalently. A top-level null, missing field, or
1474    /// unsupported non-object shape is normalized to no reasoning metadata.
1475    /// When serializing manually constructed responses, [`Self::provider_reasoning`]
1476    /// takes precedence over this field, and this field takes precedence over
1477    /// [`Self::reasoning_context`].
1478    pub reasoning_metadata: Option<Map<String, Value>>,
1479    /// The effective reasoning context returned by OpenAI.
1480    ///
1481    /// This is populated as a convenience projection of
1482    /// [`Self::reasoning_metadata`]. String-shaped reasoning returned by compatible
1483    /// providers remains available through [`Self::provider_reasoning`].
1484    pub reasoning_context: Option<String>,
1485    /// Token usage
1486    pub usage: Option<ResponsesUsage>,
1487    /// The model output (messages, etc will go here)
1488    pub output: Vec<Output>,
1489    /// Tools
1490    pub tools: Vec<ResponsesToolDefinition>,
1491    /// Additional parameters
1492    pub additional_parameters: AdditionalParameters,
1493}
1494
1495#[derive(Serialize)]
1496#[serde(untagged)]
1497enum CompletionResponseReasoningRef<'a> {
1498    Text(&'a str),
1499    Metadata(&'a Map<String, Value>),
1500    Context { context: &'a str },
1501}
1502
1503#[derive(Serialize)]
1504struct CompletionResponseWireRef<'a> {
1505    id: &'a str,
1506    object: &'a ResponseObject,
1507    created_at: u64,
1508    status: &'a ResponseStatus,
1509    error: &'a Option<ResponseError>,
1510    incomplete_details: &'a Option<IncompleteDetailsReason>,
1511    instructions: &'a Option<String>,
1512    max_output_tokens: &'a Option<u64>,
1513    model: &'a str,
1514    #[serde(skip_serializing_if = "Option::is_none")]
1515    reasoning: Option<CompletionResponseReasoningRef<'a>>,
1516    usage: &'a Option<ResponsesUsage>,
1517    output: &'a Vec<Output>,
1518    tools: &'a Vec<ResponsesToolDefinition>,
1519    #[serde(flatten)]
1520    additional_parameters: &'a AdditionalParameters,
1521}
1522
1523#[derive(Deserialize)]
1524struct CompletionResponseWire {
1525    id: String,
1526    object: ResponseObject,
1527    created_at: u64,
1528    status: ResponseStatus,
1529    error: Option<ResponseError>,
1530    incomplete_details: Option<IncompleteDetailsReason>,
1531    instructions: Option<String>,
1532    max_output_tokens: Option<u64>,
1533    model: String,
1534    #[serde(default)]
1535    reasoning: Option<Value>,
1536    usage: Option<ResponsesUsage>,
1537    #[serde(default)]
1538    output: Vec<Output>,
1539    #[serde(default)]
1540    tools: Vec<ResponsesToolDefinition>,
1541    #[serde(flatten)]
1542    additional_parameters: AdditionalParameters,
1543}
1544
1545impl Serialize for CompletionResponse {
1546    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1547    where
1548        S: Serializer,
1549    {
1550        // `AdditionalParameters::reasoning` models request configuration. A
1551        // response's top-level `reasoning` field is represented by the three
1552        // response surfaces above, so omit the request field here to avoid
1553        // serializing duplicate `reasoning` keys.
1554        let mut additional_parameters = self.additional_parameters.clone();
1555        additional_parameters.reasoning = None;
1556
1557        let reasoning = self
1558            .provider_reasoning
1559            .as_deref()
1560            .map(CompletionResponseReasoningRef::Text)
1561            .or_else(|| {
1562                self.reasoning_metadata
1563                    .as_ref()
1564                    .map(CompletionResponseReasoningRef::Metadata)
1565            })
1566            .or_else(|| {
1567                self.reasoning_context
1568                    .as_deref()
1569                    .map(|context| CompletionResponseReasoningRef::Context { context })
1570            });
1571
1572        CompletionResponseWireRef {
1573            id: &self.id,
1574            object: &self.object,
1575            created_at: self.created_at,
1576            status: &self.status,
1577            error: &self.error,
1578            incomplete_details: &self.incomplete_details,
1579            instructions: &self.instructions,
1580            max_output_tokens: &self.max_output_tokens,
1581            model: &self.model,
1582            reasoning,
1583            usage: &self.usage,
1584            output: &self.output,
1585            tools: &self.tools,
1586            additional_parameters: &additional_parameters,
1587        }
1588        .serialize(serializer)
1589    }
1590}
1591
1592impl<'de> Deserialize<'de> for CompletionResponse {
1593    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1594    where
1595        D: Deserializer<'de>,
1596    {
1597        let response = CompletionResponseWire::deserialize(deserializer)?;
1598        let (provider_reasoning, reasoning_metadata) = match response.reasoning {
1599            Some(Value::String(reasoning)) => (Some(reasoning), None),
1600            Some(Value::Object(metadata)) => (None, Some(metadata)),
1601            // Null, arrays, numbers, and booleans are not documented top-level
1602            // reasoning response shapes. Ignore them to preserve the lenient
1603            // behavior that predates object-shaped metadata support.
1604            _ => (None, None),
1605        };
1606        let reasoning_context = reasoning_metadata
1607            .as_ref()
1608            .and_then(|reasoning| reasoning.get("context"))
1609            .and_then(Value::as_str)
1610            .map(ToOwned::to_owned);
1611
1612        Ok(Self {
1613            id: response.id,
1614            object: response.object,
1615            created_at: response.created_at,
1616            status: response.status,
1617            error: response.error,
1618            incomplete_details: response.incomplete_details,
1619            instructions: response.instructions,
1620            max_output_tokens: response.max_output_tokens,
1621            model: response.model,
1622            provider_reasoning,
1623            reasoning_metadata,
1624            reasoning_context,
1625            usage: response.usage,
1626            output: response.output,
1627            tools: response.tools,
1628            additional_parameters: response.additional_parameters,
1629        })
1630    }
1631}
1632
1633/// Additional parameters for the completion request type for OpenAI's Response API: <https://platform.openai.com/docs/api-reference/responses/create>
1634/// Intended to be derived from [`crate::completion::request::CompletionRequest`].
1635#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1636pub struct AdditionalParameters {
1637    /// Whether or not a given model task should run in the background (ie a detached process).
1638    #[serde(skip_serializing_if = "Option::is_none")]
1639    pub background: Option<bool>,
1640    /// The text response format. This is where you would add structured outputs (if you want them).
1641    #[serde(skip_serializing_if = "Option::is_none")]
1642    pub text: Option<TextConfig>,
1643    /// What types of extra data you would like to include. This is mostly useless at the moment since the types of extra data to add is currently unsupported, but this will be coming soon!
1644    #[serde(skip_serializing_if = "Option::is_none")]
1645    pub include: Option<Vec<Include>>,
1646    /// `top_p`. Mutually exclusive with the `temperature` argument.
1647    #[serde(skip_serializing_if = "Option::is_none")]
1648    pub top_p: Option<f64>,
1649    /// Whether or not the response should be truncated.
1650    #[serde(skip_serializing_if = "Option::is_none")]
1651    pub truncation: Option<TruncationStrategy>,
1652    /// The username of the user (that you want to use).
1653    #[serde(skip_serializing_if = "Option::is_none")]
1654    pub user: Option<String>,
1655    /// A stable cache routing key for prompt caching.
1656    #[serde(skip_serializing_if = "Option::is_none")]
1657    pub prompt_cache_key: Option<String>,
1658    /// Prompt cache retention policy.
1659    #[serde(skip_serializing_if = "Option::is_none")]
1660    pub prompt_cache_retention: Option<String>,
1661    /// Any additional metadata you'd like to add. This will additionally be returned by the response.
1662    #[serde(
1663        skip_serializing_if = "Map::is_empty",
1664        default,
1665        deserialize_with = "deserialize_metadata"
1666    )]
1667    pub metadata: serde_json::Map<String, serde_json::Value>,
1668    /// Whether or not you want tool calls to run in parallel.
1669    #[serde(skip_serializing_if = "Option::is_none")]
1670    pub parallel_tool_calls: Option<bool>,
1671    /// Previous response ID. If you are not sending a full conversation, this can help to track the message flow.
1672    #[serde(skip_serializing_if = "Option::is_none")]
1673    pub previous_response_id: Option<String>,
1674    /// Add thinking/reasoning to your response. The response will be emitted as a list member of the `output` field.
1675    #[serde(skip_serializing_if = "Option::is_none")]
1676    pub reasoning: Option<Reasoning>,
1677    /// The service tier you're using.
1678    #[serde(skip_serializing_if = "Option::is_none")]
1679    pub service_tier: Option<OpenAIServiceTier>,
1680    /// Whether or not to store the response for later retrieval by API.
1681    #[serde(skip_serializing_if = "Option::is_none")]
1682    pub store: Option<bool>,
1683}
1684
1685fn deserialize_metadata<'de, D>(
1686    deserializer: D,
1687) -> Result<serde_json::Map<String, serde_json::Value>, D::Error>
1688where
1689    D: Deserializer<'de>,
1690{
1691    Ok(
1692        Option::<serde_json::Map<String, serde_json::Value>>::deserialize(deserializer)?
1693            .unwrap_or_default(),
1694    )
1695}
1696
1697impl AdditionalParameters {
1698    pub fn to_json(self) -> serde_json::Value {
1699        serde_json::to_value(self).unwrap_or_else(|_| serde_json::Value::Object(Map::new()))
1700    }
1701}
1702
1703/// The truncation strategy.
1704/// When using auto, if the context of this response and previous ones exceeds the model's context window size, the model will truncate the response to fit the context window by dropping input items in the middle of the conversation.
1705/// Otherwise, does nothing (and is disabled by default).
1706#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1707#[serde(rename_all = "snake_case")]
1708pub enum TruncationStrategy {
1709    Auto,
1710    #[default]
1711    Disabled,
1712}
1713
1714/// The model output format configuration.
1715/// You can either have plain text by default, or attach a JSON schema for the purposes of structured outputs.
1716#[derive(Clone, Debug, Serialize, Deserialize)]
1717pub struct TextConfig {
1718    pub format: TextFormat,
1719}
1720
1721impl TextConfig {
1722    pub(crate) fn structured_output<S>(name: S, schema: serde_json::Value) -> Self
1723    where
1724        S: Into<String>,
1725    {
1726        Self {
1727            format: TextFormat::JsonSchema(StructuredOutputsInput {
1728                name: name.into(),
1729                schema,
1730                strict: true,
1731            }),
1732        }
1733    }
1734}
1735
1736/// The text format (contained by [`TextConfig`]).
1737/// You can either have plain text by default, or attach a JSON schema for the purposes of structured outputs.
1738#[derive(Clone, Debug, Serialize, Deserialize, Default)]
1739#[serde(tag = "type")]
1740#[serde(rename_all = "snake_case")]
1741pub enum TextFormat {
1742    JsonSchema(StructuredOutputsInput),
1743    #[default]
1744    Text,
1745}
1746
1747/// The inputs required for adding structured outputs.
1748#[derive(Clone, Debug, Serialize, Deserialize)]
1749pub struct StructuredOutputsInput {
1750    /// The name of your schema.
1751    pub name: String,
1752    /// Your required output schema. It is recommended that you use the JsonSchema macro, which you can check out at <https://docs.rs/schemars/latest/schemars/trait.JsonSchema.html>.
1753    pub schema: serde_json::Value,
1754    /// Enable strict output. If you are using your AI agent in a data pipeline or another scenario that requires the data to be absolutely fixed to a given schema, it is recommended to set this to true.
1755    #[serde(default)]
1756    pub strict: bool,
1757}
1758
1759/// Add reasoning to a [`CompletionRequest`].
1760///
1761/// # Example
1762/// ```
1763/// use rig_core::providers::openai::responses_api::{
1764///     Reasoning, ReasoningContext, ReasoningEffort, ReasoningMode,
1765/// };
1766///
1767/// // GPT-5.6 reasoning controls: effort, pro mode, and persisted-reasoning context.
1768/// let reasoning = Reasoning::new()
1769///     .with_effort(ReasoningEffort::Max)
1770///     .with_mode(ReasoningMode::Pro)
1771///     .with_context(ReasoningContext::AllTurns);
1772/// ```
1773#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1774pub struct Reasoning {
1775    /// How much effort you want the model to put into thinking/reasoning.
1776    #[serde(skip_serializing_if = "Option::is_none")]
1777    pub effort: Option<ReasoningEffort>,
1778    /// How much effort you want the model to put into writing the reasoning summary.
1779    #[serde(skip_serializing_if = "Option::is_none")]
1780    pub summary: Option<ReasoningSummaryLevel>,
1781    /// The reasoning mode. Independent from `effort`; the standard mode is
1782    /// represented by omitting the field. Supported by the GPT-5.6 model family.
1783    #[serde(skip_serializing_if = "Option::is_none")]
1784    pub mode: Option<ReasoningMode>,
1785    /// How persisted reasoning is carried across turns. Supported by the
1786    /// GPT-5.6 model family.
1787    #[serde(skip_serializing_if = "Option::is_none")]
1788    pub context: Option<ReasoningContext>,
1789}
1790
1791impl Reasoning {
1792    /// Creates a new Reasoning instantiation (with empty values).
1793    pub fn new() -> Self {
1794        Self::default()
1795    }
1796
1797    /// Adds reasoning effort.
1798    pub fn with_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
1799        self.effort = Some(reasoning_effort);
1800
1801        self
1802    }
1803
1804    /// Adds summary level (how detailed the reasoning summary will be).
1805    pub fn with_summary_level(mut self, reasoning_summary_level: ReasoningSummaryLevel) -> Self {
1806        self.summary = Some(reasoning_summary_level);
1807
1808        self
1809    }
1810
1811    /// Sets the reasoning mode (e.g. pro mode on GPT-5.6 models).
1812    pub fn with_mode(mut self, reasoning_mode: ReasoningMode) -> Self {
1813        self.mode = Some(reasoning_mode);
1814
1815        self
1816    }
1817
1818    /// Sets how persisted reasoning is carried across turns (GPT-5.6 models).
1819    pub fn with_context(mut self, reasoning_context: ReasoningContext) -> Self {
1820        self.context = Some(reasoning_context);
1821
1822        self
1823    }
1824}
1825
1826/// The billing service tier that will be used. On auto by default.
1827#[derive(Clone, Debug, Default)]
1828pub enum OpenAIServiceTier {
1829    /// Let OpenAI choose the service tier.
1830    #[default]
1831    Auto,
1832    /// Use the default service tier.
1833    Default,
1834    /// Use the flex service tier.
1835    Flex,
1836    /// Use the priority service tier.
1837    Priority,
1838    /// Use the standard service tier returned by OpenAI-compatible providers.
1839    Standard,
1840    /// Preserve an unknown provider-specific service tier.
1841    Other(String),
1842}
1843
1844impl Serialize for OpenAIServiceTier {
1845    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1846    where
1847        S: Serializer,
1848    {
1849        serializer.serialize_str(match self {
1850            Self::Auto => "auto",
1851            Self::Default => "default",
1852            Self::Flex => "flex",
1853            Self::Priority => "priority",
1854            Self::Standard => "standard",
1855            Self::Other(value) => value,
1856        })
1857    }
1858}
1859
1860impl<'de> Deserialize<'de> for OpenAIServiceTier {
1861    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1862    where
1863        D: Deserializer<'de>,
1864    {
1865        let value = String::deserialize(deserializer)?;
1866        Ok(match value.as_str() {
1867            "auto" => Self::Auto,
1868            "default" => Self::Default,
1869            "flex" => Self::Flex,
1870            "priority" => Self::Priority,
1871            "standard" => Self::Standard,
1872            _ => Self::Other(value),
1873        })
1874    }
1875}
1876
1877/// The amount of reasoning effort that will be used by a given model.
1878#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1879#[serde(rename_all = "snake_case")]
1880pub enum ReasoningEffort {
1881    None,
1882    Minimal,
1883    Low,
1884    #[default]
1885    Medium,
1886    High,
1887    Xhigh,
1888    /// The highest reasoning effort. Supported by the GPT-5.6 model family.
1889    Max,
1890}
1891
1892/// The reasoning mode used by a given model. Independent from
1893/// [`ReasoningEffort`]; the standard mode is represented by omitting the field
1894/// (`None` on [`Reasoning::mode`]), so this enum only carries the documented
1895/// non-default modes.
1896#[derive(Clone, Debug, Serialize, Deserialize)]
1897#[serde(rename_all = "snake_case")]
1898pub enum ReasoningMode {
1899    /// Pro mode. Supported by the GPT-5.6 model family.
1900    Pro,
1901}
1902
1903/// How persisted reasoning is carried across turns. Supported by the GPT-5.6
1904/// model family.
1905#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1906#[serde(rename_all = "snake_case")]
1907pub enum ReasoningContext {
1908    /// Let the model decide how much persisted reasoning to reuse.
1909    #[default]
1910    Auto,
1911    /// Reuse persisted reasoning from all previous turns.
1912    AllTurns,
1913    /// Only use reasoning from the current turn.
1914    CurrentTurn,
1915}
1916
1917/// The amount of effort that will go into a reasoning summary by a given model.
1918#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1919#[serde(rename_all = "snake_case")]
1920pub enum ReasoningSummaryLevel {
1921    #[default]
1922    Auto,
1923    Concise,
1924    Detailed,
1925}
1926
1927/// Results to additionally include in the OpenAI Responses API.
1928/// Note that most of these are currently unsupported, but have been added for completeness.
1929#[derive(Clone, Debug, Deserialize, Serialize)]
1930pub enum Include {
1931    #[serde(rename = "file_search_call.results")]
1932    FileSearchCallResults,
1933    #[serde(rename = "message.input_image.image_url")]
1934    MessageInputImageImageUrl,
1935    #[serde(rename = "computer_call.output.image_url")]
1936    ComputerCallOutputOutputImageUrl,
1937    #[serde(rename = "reasoning.encrypted_content")]
1938    ReasoningEncryptedContent,
1939    #[serde(rename = "code_interpreter_call.outputs")]
1940    CodeInterpreterCallOutputs,
1941}
1942
1943/// A modeled output item from the OpenAI Responses API.
1944///
1945/// Unrecognized output items — notably provider-native hosted tools such as
1946/// `web_search_call`, `file_search_call`, `computer_call`, and
1947/// `code_interpreter_call` — decode to [`Output::Unknown`], which preserves
1948/// the verbatim item object so callers can inspect or forward it. This keeps
1949/// unknown item types from breaking deserialization of the entire
1950/// `CompletionResponse` (the invariant that previously caused streaming token
1951/// usage to be silently dropped) without discarding the payload along the way.
1952#[derive(Clone, Debug, PartialEq)]
1953pub enum Output {
1954    Message(OutputMessage),
1955    FunctionCall(OutputFunctionCall),
1956    Reasoning {
1957        id: String,
1958        summary: Vec<ReasoningSummary>,
1959        content: Vec<String>,
1960        encrypted_content: Option<String>,
1961        status: Option<ToolStatus>,
1962    },
1963    /// Catch-all for output item types this version does not model. Holds the
1964    /// raw item object exactly as it appeared in the provider's `output[]`
1965    /// array, so hosted-tool payloads survive the typed decode.
1966    Unknown(Value),
1967}
1968
1969/// Deserialize helper for the inline-field [`Output::Reasoning`] variant.
1970///
1971/// `Output`'s (de)serialization is hand-written so [`Output::Unknown`] can carry
1972/// a raw [`Value`] (`#[serde(other)]` only applies to a unit variant, which
1973/// would force the payload to be dropped). The modeled `Message`/`FunctionCall`
1974/// variants deserialize straight into their payload structs; `Reasoning` has no
1975/// payload struct of its own, so this mirrors its fields. Same approach as
1976/// Anthropic's `Citation`.
1977#[derive(Deserialize)]
1978struct ReasoningFields {
1979    id: String,
1980    #[serde(default)]
1981    summary: Vec<ReasoningSummary>,
1982    #[serde(default, deserialize_with = "deserialize_reasoning_text_content")]
1983    content: Vec<String>,
1984    #[serde(default)]
1985    encrypted_content: Option<String>,
1986    #[serde(default)]
1987    status: Option<ToolStatus>,
1988}
1989
1990impl From<ReasoningFields> for Output {
1991    fn from(fields: ReasoningFields) -> Self {
1992        Output::Reasoning {
1993            id: fields.id,
1994            summary: fields.summary,
1995            content: fields.content,
1996            encrypted_content: fields.encrypted_content,
1997            status: fields.status,
1998        }
1999    }
2000}
2001
2002/// Serialize a modeled payload as its tagged wire object — the payload's own
2003/// fields plus the internally tagged `"type"`. The key is appended, so the
2004/// result is value-equal (not byte-for-byte ordered) to the original item.
2005fn tagged_output_object<T>(tag: &str, payload: &T) -> Result<Value, serde_json::Error>
2006where
2007    T: Serialize,
2008{
2009    let mut value = serde_json::to_value(payload)?;
2010    let map = value.as_object_mut().ok_or_else(|| {
2011        <serde_json::Error as serde::ser::Error>::custom(
2012            "output payload must serialize to a JSON object",
2013        )
2014    })?;
2015    map.insert("type".to_string(), Value::String(tag.to_string()));
2016    Ok(value)
2017}
2018
2019impl Serialize for Output {
2020    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2021    where
2022        S: Serializer,
2023    {
2024        // Hand-written to keep `Unknown` verbatim (mirrors Anthropic's
2025        // `Citation`). Known variants emit their modeled fields plus the
2026        // internally tagged `type`; `Unknown` re-emits its raw value. The result
2027        // is value-equal — not byte-for-byte — to the wire item, since `type` is
2028        // appended rather than threaded in declaration order.
2029        let value = match self {
2030            Output::Message(message) => tagged_output_object("message", message),
2031            Output::FunctionCall(call) => tagged_output_object("function_call", call),
2032            Output::Reasoning {
2033                id,
2034                summary,
2035                content,
2036                encrypted_content,
2037                status,
2038            } => {
2039                let mut value = serde_json::json!({
2040                    "type": "reasoning",
2041                    "id": id,
2042                    "summary": summary,
2043                    "encrypted_content": encrypted_content,
2044                    "status": status,
2045                });
2046                if !content.is_empty() {
2047                    let map = value.as_object_mut().ok_or_else(|| {
2048                        serde::ser::Error::custom("reasoning output must serialize to an object")
2049                    })?;
2050                    map.insert("content".to_string(), reasoning_text_content_json(content));
2051                }
2052                Ok(value)
2053            }
2054            Output::Unknown(value) => return value.serialize(serializer),
2055        };
2056        value
2057            .map_err(serde::ser::Error::custom)?
2058            .serialize(serializer)
2059    }
2060}
2061
2062impl<'de> Deserialize<'de> for Output {
2063    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2064    where
2065        D: Deserializer<'de>,
2066    {
2067        // Decode to a `Value` first so an unmodeled item is captured verbatim as
2068        // `Unknown`. A modeled `type` with a malformed body still errors (rather
2069        // than silently degrading to `Unknown`); an absent or non-string `type`
2070        // is itself unmodeled and is captured as `Unknown`. Mirrors `Citation`.
2071        let value = Value::deserialize(deserializer)?;
2072        let Some(tag) = value.get("type").and_then(Value::as_str) else {
2073            return Ok(Output::Unknown(value));
2074        };
2075        match tag {
2076            "message" => serde_json::from_value(value)
2077                .map(Output::Message)
2078                .map_err(serde::de::Error::custom),
2079            "function_call" => serde_json::from_value(value)
2080                .map(Output::FunctionCall)
2081                .map_err(serde::de::Error::custom),
2082            "reasoning" => serde_json::from_value::<ReasoningFields>(value)
2083                .map(Output::from)
2084                .map_err(serde::de::Error::custom),
2085            _ => Ok(Output::Unknown(value)),
2086        }
2087    }
2088}
2089
2090impl From<Output> for Vec<completion::AssistantContent> {
2091    fn from(value: Output) -> Self {
2092        let res: Vec<completion::AssistantContent> = match value {
2093            Output::Message(OutputMessage { content, .. }) => content
2094                .into_iter()
2095                .map(completion::AssistantContent::from)
2096                .collect(),
2097            Output::FunctionCall(OutputFunctionCall {
2098                id,
2099                arguments,
2100                call_id,
2101                name,
2102                ..
2103            }) => vec![completion::AssistantContent::tool_call_with_call_id(
2104                id, call_id, name, arguments,
2105            )],
2106            Output::Reasoning {
2107                id,
2108                summary,
2109                content: reasoning_content,
2110                encrypted_content,
2111                ..
2112            } => {
2113                let mut content = summary
2114                    .into_iter()
2115                    .map(|summary| match summary {
2116                        ReasoningSummary::SummaryText { text } => {
2117                            message::ReasoningContent::Summary(text)
2118                        }
2119                    })
2120                    .collect::<Vec<_>>();
2121                content.extend(reasoning_content.into_iter().map(|text| {
2122                    message::ReasoningContent::Text {
2123                        text,
2124                        signature: None,
2125                    }
2126                }));
2127                if let Some(encrypted_content) =
2128                    encrypted_content.filter(|content| !content.is_empty())
2129                {
2130                    content.push(message::ReasoningContent::Encrypted(encrypted_content));
2131                }
2132                vec![completion::AssistantContent::Reasoning(
2133                    message::Reasoning {
2134                        id: Some(id),
2135                        content,
2136                    },
2137                )]
2138            }
2139            Output::Unknown(_) => Vec::new(),
2140        };
2141
2142        res
2143    }
2144}
2145
2146#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2147pub struct OutputReasoning {
2148    id: String,
2149    summary: Vec<ReasoningSummary>,
2150    status: ToolStatus,
2151}
2152
2153/// An OpenAI Responses API tool call. A call ID will be returned that must be used when creating a tool result to send back to OpenAI as a message input, otherwise an error will be received.
2154#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2155pub struct OutputFunctionCall {
2156    /// Provider-assigned `fc_...` item ID. The Responses API rejects
2157    /// `function_call` input IDs that are not native `fc` item IDs, so IDs
2158    /// minted outside the Responses API (by Rig's agent loop or another
2159    /// provider) are omitted on serialization and the call is paired with its
2160    /// output by `call_id` alone.
2161    #[serde(default, skip_serializing_if = "is_not_function_call_item_id")]
2162    pub id: String,
2163    #[serde(with = "json_utils::stringified_json")]
2164    pub arguments: serde_json::Value,
2165    pub call_id: String,
2166    pub name: String,
2167    pub status: ToolStatus,
2168}
2169
2170/// See [`OutputFunctionCall::id`]: only provider-native `fc` item IDs may be
2171/// sent back to the Responses API.
2172fn is_not_function_call_item_id(id: &str) -> bool {
2173    !id.starts_with("fc_")
2174}
2175
2176/// The status of a given tool.
2177#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2178#[serde(rename_all = "snake_case")]
2179pub enum ToolStatus {
2180    InProgress,
2181    Completed,
2182    Incomplete,
2183}
2184
2185/// An output message from OpenAI's Responses API.
2186#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2187pub struct OutputMessage {
2188    /// The message ID. Must be included when sending the message back to OpenAI
2189    pub id: String,
2190    /// The role (currently only Assistant is available as this struct is only created when receiving an LLM message as a response)
2191    pub role: OutputRole,
2192    /// The status of the response
2193    pub status: ResponseStatus,
2194    /// The actual message content
2195    pub content: Vec<AssistantContent>,
2196}
2197
2198/// The role of an output message.
2199#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
2200#[serde(rename_all = "snake_case")]
2201pub enum OutputRole {
2202    Assistant,
2203}
2204
2205impl<Ext, H> completion::CompletionModel for GenericResponsesCompletionModel<Ext, H>
2206where
2207    crate::client::Client<Ext, H>:
2208        HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
2209    Ext: crate::client::Provider
2210        + ResponsesProviderExt
2211        + crate::client::DebugExt
2212        + Clone
2213        + WasmCompatSend
2214        + WasmCompatSync
2215        + 'static,
2216    H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
2217{
2218    type Response = CompletionResponse;
2219    type StreamingResponse = StreamingCompletionResponse;
2220
2221    type Client = crate::client::Client<Ext, H>;
2222
2223    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
2224        Self::new(client.clone(), model)
2225    }
2226
2227    // The OpenAI Responses API constrains only the final assistant message via
2228    // `text.format`; tools are still called across turns, so native structured
2229    // output composes with tool calls. See issue #1928.
2230    fn composes_native_output_with_tools(&self) -> bool {
2231        true
2232    }
2233
2234    async fn completion(
2235        &self,
2236        completion_request: crate::completion::CompletionRequest,
2237    ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
2238        let system_instructions = completion_request.preamble.clone();
2239        let record_telemetry_content = completion_request.record_telemetry_content;
2240        let request = self.create_completion_request(completion_request)?;
2241        let span = CompletionSpanBuilder::new("openai", &request.model, CompletionOperation::Chat)
2242            .system_instructions(system_instructions.as_deref(), record_telemetry_content)
2243            .build();
2244        let body = serde_json::to_vec(&request)?;
2245
2246        if enabled!(Level::TRACE) {
2247            tracing::trace!(
2248                target: "rig::completions",
2249                "OpenAI Responses completion request: {request}",
2250                request = serde_json::to_string_pretty(&request)?
2251            );
2252        }
2253
2254        let req = self
2255            .client
2256            .post("/responses")?
2257            .body(body)
2258            .map_err(|e| CompletionError::HttpError(e.into()))?;
2259
2260        async move {
2261            let response = self.client.send(req).await?;
2262
2263            if response.status().is_success() {
2264                let t = http_client::text(response).await?;
2265                let response = serde_json::from_str::<Self::Response>(&t)?;
2266                let span = tracing::Span::current();
2267                span.record("gen_ai.response.id", &response.id);
2268                span.record("gen_ai.response.model", &response.model);
2269                if let Some(ref usage) = response.usage {
2270                    span.record("gen_ai.usage.output_tokens", usage.output_tokens);
2271                    span.record("gen_ai.usage.input_tokens", usage.input_tokens);
2272                    let cached_tokens = usage
2273                        .input_tokens_details
2274                        .as_ref()
2275                        .map(|d| d.cached_tokens)
2276                        .unwrap_or(0);
2277                    span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
2278                }
2279                if enabled!(Level::TRACE) {
2280                    tracing::trace!(
2281                        target: "rig::completions",
2282                        "OpenAI Responses completion response: {response}",
2283                        response = serde_json::to_string_pretty(&response)?
2284                    );
2285                }
2286                response.try_into()
2287            } else {
2288                let status = response.status();
2289                let text = http_client::text(response).await?;
2290                Err(CompletionError::from_http_response(status, text))
2291            }
2292        }
2293        .instrument(span)
2294        .await
2295    }
2296
2297    async fn stream(
2298        &self,
2299        request: crate::completion::CompletionRequest,
2300    ) -> Result<
2301        crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
2302        CompletionError,
2303    > {
2304        GenericResponsesCompletionModel::stream(self, request).await
2305    }
2306}
2307
2308impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
2309    type Error = CompletionError;
2310
2311    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
2312        // Extract the msg_ ID from the first Output::Message item
2313        let message_id = response.output.iter().find_map(|item| match item {
2314            Output::Message(msg) => Some(msg.id.clone()),
2315            _ => None,
2316        });
2317
2318        let output_content: Vec<completion::AssistantContent> = response
2319            .output
2320            .iter()
2321            .cloned()
2322            .flat_map(<Vec<completion::AssistantContent>>::from)
2323            .collect();
2324        let has_structured_reasoning = response
2325            .output
2326            .iter()
2327            .any(|item| matches!(item, Output::Reasoning { .. }));
2328        let content = response
2329            .provider_reasoning
2330            .as_ref()
2331            .filter(|reasoning| !has_structured_reasoning && !reasoning.is_empty())
2332            .map(|reasoning| {
2333                let mut content = Vec::with_capacity(output_content.len() + 1);
2334                content.push(completion::AssistantContent::Reasoning(
2335                    message::Reasoning::new(reasoning),
2336                ));
2337                content.extend(output_content.clone());
2338                content
2339            })
2340            .unwrap_or(output_content);
2341
2342        let choice = OneOrMany::many(content).map_err(|_| {
2343            CompletionError::ResponseError(
2344                "Response contained no message or tool call (empty)".to_owned(),
2345            )
2346        })?;
2347
2348        let usage = response
2349            .usage
2350            .as_ref()
2351            .map(GetTokenUsage::token_usage)
2352            .unwrap_or_default();
2353
2354        Ok(completion::CompletionResponse {
2355            choice,
2356            usage,
2357            raw_response: response,
2358            message_id,
2359        })
2360    }
2361}
2362
2363/// An OpenAI Responses API message.
2364#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2365#[serde(tag = "role", rename_all = "lowercase")]
2366pub enum Message {
2367    #[serde(alias = "developer")]
2368    System {
2369        #[serde(deserialize_with = "string_or_one_or_many")]
2370        content: OneOrMany<SystemContent>,
2371        #[serde(skip_serializing_if = "Option::is_none")]
2372        name: Option<String>,
2373    },
2374    User {
2375        #[serde(deserialize_with = "string_or_one_or_many")]
2376        content: OneOrMany<UserContent>,
2377        #[serde(skip_serializing_if = "Option::is_none")]
2378        name: Option<String>,
2379    },
2380    Assistant {
2381        content: OneOrMany<AssistantContentType>,
2382        #[serde(skip_serializing_if = "String::is_empty")]
2383        id: String,
2384        #[serde(skip_serializing_if = "Option::is_none")]
2385        name: Option<String>,
2386        status: ToolStatus,
2387    },
2388    #[serde(rename = "assistant", skip_deserializing)]
2389    AssistantInput {
2390        content: String,
2391        #[serde(skip_serializing_if = "Option::is_none")]
2392        name: Option<String>,
2393    },
2394    #[serde(rename = "tool")]
2395    ToolResult {
2396        tool_call_id: String,
2397        output: ToolResultOutput,
2398    },
2399}
2400
2401/// The type of a tool result content item.
2402#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
2403#[serde(rename_all = "lowercase")]
2404pub enum ToolResultContentType {
2405    #[default]
2406    Text,
2407}
2408
2409impl Message {
2410    pub fn system(content: &str) -> Self {
2411        Message::System {
2412            content: OneOrMany::one(content.to_owned().into()),
2413            name: None,
2414        }
2415    }
2416}
2417
2418/// Text assistant content.
2419/// Note that the text type in comparison to the Completions API is actually `output_text` rather than `text`.
2420#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2421#[serde(tag = "type", rename_all = "snake_case")]
2422pub enum AssistantContent {
2423    OutputText(Text),
2424    Refusal { refusal: String },
2425}
2426
2427impl From<AssistantContent> for completion::AssistantContent {
2428    fn from(value: AssistantContent) -> Self {
2429        match value {
2430            AssistantContent::Refusal { refusal } => {
2431                completion::AssistantContent::Text(Text::new(refusal))
2432            }
2433            AssistantContent::OutputText(Text { text, .. }) => {
2434                completion::AssistantContent::Text(Text::new(text))
2435            }
2436        }
2437    }
2438}
2439
2440/// The type of assistant content.
2441#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2442#[serde(untagged)]
2443pub enum AssistantContentType {
2444    Text(AssistantContent),
2445    ToolCall(OutputFunctionCall),
2446    Reasoning(OpenAIReasoning),
2447}
2448
2449/// System content for the OpenAI Responses API.
2450/// Uses `input_text` type to match the Responses API format.
2451#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2452#[serde(tag = "type", rename_all = "snake_case")]
2453pub enum SystemContent {
2454    InputText { text: String },
2455}
2456
2457impl From<String> for SystemContent {
2458    fn from(s: String) -> Self {
2459        SystemContent::InputText { text: s }
2460    }
2461}
2462
2463impl std::str::FromStr for SystemContent {
2464    type Err = std::convert::Infallible;
2465
2466    fn from_str(s: &str) -> Result<Self, Self::Err> {
2467        Ok(SystemContent::InputText {
2468            text: s.to_string(),
2469        })
2470    }
2471}
2472
2473/// Different types of user content.
2474#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2475#[serde(tag = "type", rename_all = "snake_case")]
2476pub enum UserContent {
2477    InputText {
2478        text: String,
2479    },
2480    InputImage {
2481        image_url: String,
2482        #[serde(default)]
2483        detail: ImageDetail,
2484    },
2485    InputFile {
2486        #[serde(skip_serializing_if = "Option::is_none")]
2487        file_id: Option<String>,
2488        #[serde(skip_serializing_if = "Option::is_none")]
2489        file_url: Option<String>,
2490        #[serde(skip_serializing_if = "Option::is_none")]
2491        file_data: Option<String>,
2492        #[serde(skip_serializing_if = "Option::is_none")]
2493        filename: Option<String>,
2494    },
2495    Audio {
2496        input_audio: InputAudio,
2497    },
2498    #[serde(rename = "tool")]
2499    ToolResult {
2500        tool_call_id: String,
2501        output: String,
2502    },
2503}
2504
2505fn flush_responses_user_content(
2506    messages: &mut Vec<Message>,
2507    pending: &mut Vec<UserContent>,
2508) -> Result<(), MessageError> {
2509    if pending.is_empty() {
2510        return Ok(());
2511    }
2512
2513    let content = OneOrMany::many(std::mem::take(pending)).map_err(|_| {
2514        MessageError::ConversionError(
2515            "User message did not contain OpenAI Responses-compatible content".to_string(),
2516        )
2517    })?;
2518    messages.push(Message::User {
2519        content,
2520        name: None,
2521    });
2522    Ok(())
2523}
2524
2525fn responses_user_content(content: message::UserContent) -> Result<UserContent, MessageError> {
2526    match content {
2527        message::UserContent::Text(message::Text { text, .. }) => {
2528            Ok(UserContent::InputText { text })
2529        }
2530        message::UserContent::Image(message::Image {
2531            data,
2532            detail,
2533            media_type,
2534            ..
2535        }) => {
2536            let url = match data {
2537                DocumentSourceKind::Base64(data) => {
2538                    let media_type = media_type
2539                        .map(|media_type| media_type.to_mime_type().to_string())
2540                        .unwrap_or_default();
2541                    format!("data:{media_type};base64,{data}")
2542                }
2543                DocumentSourceKind::Url(url) => url,
2544                DocumentSourceKind::Raw(_) => {
2545                    return Err(MessageError::ConversionError(
2546                        "Raw files not supported, encode as base64 first".into(),
2547                    ));
2548                }
2549                doc => {
2550                    return Err(MessageError::ConversionError(format!(
2551                        "Unsupported document type: {doc}"
2552                    )));
2553                }
2554            };
2555
2556            Ok(UserContent::InputImage {
2557                image_url: url,
2558                detail: detail.unwrap_or_default(),
2559            })
2560        }
2561        message::UserContent::Document(message::Document {
2562            data: DocumentSourceKind::FileId(file_id),
2563            ..
2564        }) => Ok(UserContent::InputFile {
2565            file_id: Some(file_id),
2566            file_url: None,
2567            file_data: None,
2568            filename: None,
2569        }),
2570        message::UserContent::Document(message::Document {
2571            media_type: Some(DocumentMediaType::PDF),
2572            data,
2573            ..
2574        }) => {
2575            let (file_data, file_url, filename) = match data {
2576                DocumentSourceKind::Base64(data) => (
2577                    Some(format!("data:application/pdf;base64,{data}")),
2578                    None,
2579                    Some("document.pdf".to_string()),
2580                ),
2581                DocumentSourceKind::Url(url) => (None, Some(url), None),
2582                DocumentSourceKind::Raw(_) => {
2583                    return Err(MessageError::ConversionError(
2584                        "Raw files not supported, encode as base64 first".into(),
2585                    ));
2586                }
2587                doc => {
2588                    return Err(MessageError::ConversionError(format!(
2589                        "Unsupported document type: {doc}"
2590                    )));
2591                }
2592            };
2593
2594            Ok(UserContent::InputFile {
2595                file_id: None,
2596                file_url,
2597                file_data,
2598                filename,
2599            })
2600        }
2601        message::UserContent::Document(message::Document {
2602            data: DocumentSourceKind::Base64(text),
2603            ..
2604        }) => Ok(UserContent::InputText { text }),
2605        message::UserContent::Audio(message::Audio {
2606            data: DocumentSourceKind::Base64(data),
2607            media_type,
2608            ..
2609        }) => Ok(UserContent::Audio {
2610            input_audio: InputAudio {
2611                data,
2612                format: media_type.unwrap_or(AudioMediaType::MP3),
2613            },
2614        }),
2615        message::UserContent::Audio(_) => Err(MessageError::ConversionError(
2616            "Audio must be base64 encoded data".into(),
2617        )),
2618        _ => Err(MessageError::ConversionError(
2619            "Unsupported user content for OpenAI Responses API".into(),
2620        )),
2621    }
2622}
2623
2624fn responses_tool_result(tool_result: message::ToolResult) -> Result<Message, MessageError> {
2625    let output = responses_tool_result_output(tool_result.content)?;
2626
2627    Ok(Message::ToolResult {
2628        tool_call_id: tool_result.call_id.ok_or_else(|| {
2629            MessageError::ConversionError(
2630                "Tool result `call_id` is required for OpenAI Responses API".into(),
2631            )
2632        })?,
2633        output,
2634    })
2635}
2636
2637impl TryFrom<message::Message> for Vec<Message> {
2638    type Error = message::MessageError;
2639
2640    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
2641        match message {
2642            message::Message::System { content } => Ok(vec![Message::System {
2643                content: OneOrMany::one(content.into()),
2644                name: None,
2645            }]),
2646            message::Message::User { content } => {
2647                let mut messages = Vec::new();
2648                let mut pending = Vec::new();
2649
2650                for content in content {
2651                    match content {
2652                        message::UserContent::ToolResult(tool_result) => {
2653                            flush_responses_user_content(&mut messages, &mut pending)?;
2654                            messages.push(responses_tool_result(tool_result)?);
2655                        }
2656                        content => pending.push(responses_user_content(content)?),
2657                    }
2658                }
2659
2660                flush_responses_user_content(&mut messages, &mut pending)?;
2661                Ok(messages)
2662            }
2663            message::Message::Assistant {
2664                content,
2665                id: assistant_message_id,
2666            } => {
2667                let mut messages = Vec::new();
2668
2669                for assistant_content in content {
2670                    match assistant_content {
2671                        crate::message::AssistantContent::Text(Text { text, .. }) => {
2672                            if text.is_empty() {
2673                                continue;
2674                            }
2675                            if let Some(id) = assistant_message_id.clone() {
2676                                messages.push(Message::Assistant {
2677                                    id,
2678                                    status: ToolStatus::Completed,
2679                                    content: OneOrMany::one(AssistantContentType::Text(
2680                                        AssistantContent::OutputText(Text::new(text)),
2681                                    )),
2682                                    name: None,
2683                                });
2684                            } else {
2685                                messages.push(Message::AssistantInput {
2686                                    content: text,
2687                                    name: None,
2688                                });
2689                            }
2690                        }
2691                        crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
2692                            id: tool_id,
2693                            call_id,
2694                            function,
2695                            ..
2696                        }) => {
2697                            messages.push(Message::Assistant {
2698                                content: OneOrMany::one(AssistantContentType::ToolCall(
2699                                    OutputFunctionCall {
2700                                        call_id: call_id.ok_or_else(|| {
2701                                            MessageError::ConversionError(
2702                                                "Tool call `call_id` is required for OpenAI Responses API"
2703                                                    .into(),
2704                                            )
2705                                        })?,
2706                                        arguments: function.arguments,
2707                                        id: tool_id,
2708                                        name: function.name,
2709                                        status: ToolStatus::Completed,
2710                                    },
2711                                )),
2712                                id: assistant_message_id.clone().unwrap_or_default(),
2713                                name: None,
2714                                status: ToolStatus::Completed,
2715                            });
2716                        }
2717                        crate::message::AssistantContent::Reasoning(reasoning) => {
2718                            if let Some(openai_reasoning) = openai_reasoning_from_core(&reasoning)?
2719                            {
2720                                messages.push(Message::Assistant {
2721                                    content: OneOrMany::one(AssistantContentType::Reasoning(
2722                                        openai_reasoning,
2723                                    )),
2724                                    id: assistant_message_id.clone().unwrap_or_default(),
2725                                    name: None,
2726                                    status: ToolStatus::Completed,
2727                                });
2728                            }
2729                        }
2730                        crate::message::AssistantContent::Image(_) => {
2731                            return Err(MessageError::ConversionError(
2732                                "Assistant image content is not supported in OpenAI Responses API"
2733                                    .into(),
2734                            ));
2735                        }
2736                    }
2737                }
2738
2739                Ok(messages)
2740            }
2741        }
2742    }
2743}
2744
2745impl FromStr for UserContent {
2746    type Err = Infallible;
2747
2748    fn from_str(s: &str) -> Result<Self, Self::Err> {
2749        Ok(UserContent::InputText {
2750            text: s.to_string(),
2751        })
2752    }
2753}
2754
2755#[cfg(test)]
2756mod tests {
2757    use super::*;
2758    use crate::completion::CompletionRequestBuilder;
2759    use crate::message;
2760    use crate::test_utils::MockCompletionModel;
2761    use serde_json::json;
2762    use std::collections::HashMap;
2763
2764    fn test_document(id: &str, text: &str) -> crate::completion::Document {
2765        crate::completion::Document {
2766            id: id.to_string(),
2767            text: text.to_string(),
2768            additional_props: HashMap::new(),
2769        }
2770    }
2771
2772    fn weather_tool_definition() -> completion::ToolDefinition {
2773        completion::ToolDefinition {
2774            name: "get_weather".to_string(),
2775            description: "Get the weather".to_string(),
2776            parameters: json!({
2777                "type": "object",
2778                "properties": {
2779                    "location": {"type": "string"},
2780                    "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
2781                },
2782                "required": ["location"]
2783            }),
2784        }
2785    }
2786
2787    fn rig_tool_result(content: message::ToolResultContent) -> message::Message {
2788        message::Message::User {
2789            content: OneOrMany::one(message::UserContent::ToolResult(message::ToolResult {
2790                id: "result-id".to_string(),
2791                call_id: Some("call-id".to_string()),
2792                content: OneOrMany::one(content),
2793            })),
2794        }
2795    }
2796
2797    #[test]
2798    fn mixed_user_content_preserves_order_around_tool_results() {
2799        let input = message::Message::User {
2800            content: OneOrMany::many(vec![
2801                message::UserContent::text("before"),
2802                message::UserContent::tool_result_with_call_id(
2803                    "result-id",
2804                    "call-id".to_string(),
2805                    OneOrMany::one(message::ToolResultContent::text("tool output")),
2806                ),
2807                message::UserContent::text("after"),
2808            ])
2809            .expect("mixed content should be non-empty"),
2810        };
2811
2812        let messages = Vec::<Message>::try_from(input).expect("message conversion");
2813
2814        assert!(matches!(
2815            messages.as_slice(),
2816            [
2817                Message::User { content: before, .. },
2818                Message::ToolResult { tool_call_id, .. },
2819                Message::User { content: after, .. },
2820            ] if matches!(before.first(), UserContent::InputText { text } if text == "before")
2821                && tool_call_id == "call-id"
2822                && matches!(after.first(), UserContent::InputText { text } if text == "after")
2823        ));
2824    }
2825
2826    #[test]
2827    fn tool_result_literal_text_and_structured_json_render_without_reparsing() {
2828        let cases = [
2829            (
2830                message::ToolResultContent::text(r#"{"status":"ok"}"#),
2831                r#"{"status":"ok"}"#.to_string(),
2832            ),
2833            (
2834                message::ToolResultContent::json(json!({ "status": "ok" })),
2835                r#"{"status":"ok"}"#.to_string(),
2836            ),
2837        ];
2838
2839        for (content, expected) in cases {
2840            let input = rig_tool_result(content);
2841
2842            let messages: Vec<Message> = input.clone().try_into().expect("message conversion");
2843            assert!(matches!(
2844                messages.as_slice(),
2845                [Message::ToolResult {
2846                    output: ToolResultOutput::Text(output),
2847                    ..
2848                }] if output == &expected
2849            ));
2850
2851            let items: Vec<InputItem> = input.try_into().expect("input item conversion");
2852            assert!(matches!(
2853                items.as_slice(),
2854                [InputItem {
2855                    input: InputContent::FunctionCallOutput(ToolResult {
2856                        output: ToolResultOutput::Text(output),
2857                        ..
2858                    }),
2859                    ..
2860                }] if output == &expected
2861            ));
2862        }
2863    }
2864
2865    #[test]
2866    fn multiple_text_tool_result_blocks_preserve_order_as_rich_function_output() {
2867        let content = OneOrMany::many(vec![
2868            message::ToolResultContent::text("first"),
2869            message::ToolResultContent::text("second"),
2870        ])
2871        .expect("multiple tool-result blocks should be non-empty");
2872
2873        let input = message::Message::User {
2874            content: OneOrMany::one(message::UserContent::ToolResult(message::ToolResult {
2875                id: "result-id".to_string(),
2876                call_id: Some("call-id".to_string()),
2877                content,
2878            })),
2879        };
2880
2881        let expected = ToolResultOutput::Content(vec![
2882            ToolResultOutputContent::InputText {
2883                text: "first".to_string(),
2884            },
2885            ToolResultOutputContent::InputText {
2886                text: "second".to_string(),
2887            },
2888        ]);
2889
2890        let messages: Vec<Message> = input.clone().try_into().expect("message conversion");
2891
2892        match messages.as_slice() {
2893            [Message::ToolResult { output, .. }] => {
2894                assert_eq!(output, &expected);
2895            }
2896            other => panic!("expected one tool result, got {other:?}"),
2897        }
2898
2899        let items: Vec<InputItem> = input.try_into().expect("input item conversion");
2900
2901        match items.as_slice() {
2902            [
2903                InputItem {
2904                    input: InputContent::FunctionCallOutput(ToolResult { output, .. }),
2905                    ..
2906                },
2907            ] => {
2908                assert_eq!(output, &expected);
2909            }
2910            other => panic!("expected one function-call output, got {other:?}"),
2911        }
2912
2913        let wire = serde_json::to_value(&items[0]).expect("input item should serialize");
2914
2915        assert_eq!(
2916            wire,
2917            json!({
2918                "type": "function_call_output",
2919                "call_id": "call-id",
2920                "output": [
2921                    {
2922                        "type": "input_text",
2923                        "text": "first"
2924                    },
2925                    {
2926                        "type": "input_text",
2927                        "text": "second"
2928                    }
2929                ],
2930                "status": "completed"
2931            })
2932        );
2933    }
2934
2935    #[test]
2936    fn multiple_text_and_json_tool_result_blocks_preserve_boundaries() {
2937        let content = OneOrMany::many(vec![
2938            message::ToolResultContent::text("before"),
2939            message::ToolResultContent::json(json!({
2940                "status": "ok"
2941            })),
2942            message::ToolResultContent::text("after"),
2943        ])
2944        .expect("multiple tool-result blocks should be non-empty");
2945
2946        let output =
2947            responses_tool_result_output(content).expect("tool-result conversion should succeed");
2948
2949        assert_eq!(
2950            output,
2951            ToolResultOutput::Content(vec![
2952                ToolResultOutputContent::InputText {
2953                    text: "before".to_string(),
2954                },
2955                ToolResultOutputContent::InputText {
2956                    text: r#"{"status":"ok"}"#.to_string(),
2957                },
2958                ToolResultOutputContent::InputText {
2959                    text: "after".to_string(),
2960                },
2961            ])
2962        );
2963    }
2964
2965    #[test]
2966    fn tool_result_images_and_text_preserve_order_as_rich_function_output() {
2967        let content = OneOrMany::many(vec![
2968            message::ToolResultContent::text("before"),
2969            message::ToolResultContent::image_base64(
2970                "aW1hZ2U=",
2971                Some(message::ImageMediaType::PNG),
2972                None,
2973            ),
2974            message::ToolResultContent::json(json!({ "after": true })),
2975        ])
2976        .expect("mixed tool output is non-empty");
2977        let input = message::Message::User {
2978            content: OneOrMany::one(message::UserContent::ToolResult(message::ToolResult {
2979                id: "result-id".to_string(),
2980                call_id: Some("call-id".to_string()),
2981                content,
2982            })),
2983        };
2984
2985        let assert_output = |output: &ToolResultOutput| {
2986            assert!(matches!(
2987                output,
2988                ToolResultOutput::Content(content)
2989                    if matches!(content.as_slice(), [
2990                        ToolResultOutputContent::InputText { text: before },
2991                        ToolResultOutputContent::InputImage { image_url, .. },
2992                        ToolResultOutputContent::InputText { text: after },
2993                    ] if before == "before"
2994                        && image_url.as_deref() == Some("data:image/png;base64,aW1hZ2U=")
2995                        && after == r#"{"after":true}"#)
2996            ));
2997        };
2998
2999        let messages: Vec<Message> = input.clone().try_into().expect("message conversion");
3000        match messages.as_slice() {
3001            [Message::ToolResult { output, .. }] => assert_output(output),
3002            other => panic!("expected one rich tool result, got {other:?}"),
3003        }
3004
3005        let items: Vec<InputItem> = input.try_into().expect("input item conversion");
3006        match items.as_slice() {
3007            [
3008                InputItem {
3009                    input: InputContent::FunctionCallOutput(ToolResult { output, .. }),
3010                    ..
3011                },
3012            ] => assert_output(output),
3013            other => panic!("expected one rich function output, got {other:?}"),
3014        }
3015    }
3016
3017    #[test]
3018    fn tool_result_file_id_image_uses_the_native_wire_field() {
3019        let input = rig_tool_result(message::ToolResultContent::Image(message::Image {
3020            data: message::DocumentSourceKind::FileId("file-image-123".to_string()),
3021            media_type: None,
3022            detail: None,
3023            additional_params: None,
3024        }));
3025
3026        let items: Vec<InputItem> = input.try_into().expect("input item conversion");
3027        let wire = serde_json::to_value(&items[0]).expect("serialize input item");
3028        assert_eq!(
3029            wire,
3030            json!({
3031                "type": "function_call_output",
3032                "call_id": "call-id",
3033                "output": [{
3034                    "type": "input_image",
3035                    "file_id": "file-image-123",
3036                    "detail": "auto"
3037                }],
3038                "status": "completed"
3039            })
3040        );
3041    }
3042
3043    fn weather_tool_request() -> completion::CompletionRequest {
3044        completion::CompletionRequest {
3045            model: None,
3046            preamble: None,
3047            chat_history: crate::OneOrMany::one(message::Message::user("what's the weather?")),
3048            documents: Vec::new(),
3049            tools: vec![weather_tool_definition()],
3050            temperature: None,
3051            max_tokens: None,
3052            tool_choice: None,
3053            additional_params: None,
3054            output_schema: None,
3055            record_telemetry_content: false,
3056        }
3057    }
3058
3059    #[test]
3060    fn responses_tool_choice_modes_serialize_as_plain_strings() {
3061        for (choice, expected) in [
3062            (message::ToolChoice::Auto, json!("auto")),
3063            (message::ToolChoice::None, json!("none")),
3064            (message::ToolChoice::Required, json!("required")),
3065        ] {
3066            let converted = ToolChoice::try_from(choice).expect("mode should convert");
3067            assert_eq!(
3068                serde_json::to_value(&converted).expect("serialize tool choice"),
3069                expected
3070            );
3071        }
3072    }
3073
3074    #[test]
3075    fn responses_tool_choice_specific_single_name_serializes_as_named_function() {
3076        let converted = ToolChoice::try_from(message::ToolChoice::Specific {
3077            function_names: vec!["get_weather".to_string()],
3078        })
3079        .expect("single specific tool should convert");
3080
3081        assert_eq!(
3082            serde_json::to_value(&converted).expect("serialize tool choice"),
3083            json!({"type": "function", "name": "get_weather"})
3084        );
3085    }
3086
3087    #[test]
3088    fn responses_tool_choice_specific_multiple_names_serialize_as_allowed_tools() {
3089        let converted = ToolChoice::try_from(message::ToolChoice::Specific {
3090            function_names: vec!["add".to_string(), "subtract".to_string()],
3091        })
3092        .expect("multiple specific tools should convert");
3093
3094        assert_eq!(
3095            serde_json::to_value(&converted).expect("serialize tool choice"),
3096            json!({
3097                "type": "allowed_tools",
3098                "mode": "required",
3099                "tools": [
3100                    {"type": "function", "name": "add"},
3101                    {"type": "function", "name": "subtract"}
3102                ]
3103            })
3104        );
3105    }
3106
3107    #[test]
3108    fn responses_tool_choice_specific_empty_names_error() {
3109        let converted = ToolChoice::try_from(message::ToolChoice::Specific {
3110            function_names: vec![],
3111        });
3112
3113        assert!(matches!(
3114            converted,
3115            Err(CompletionError::RequestError(error))
3116                if error.to_string().contains("at least one function name")
3117        ));
3118    }
3119
3120    #[test]
3121    fn responses_request_with_specific_tool_choice_serializes_named_function() {
3122        let mut request = weather_tool_request();
3123        request.tool_choice = Some(message::ToolChoice::Specific {
3124            function_names: vec!["get_weather".to_string()],
3125        });
3126
3127        let request =
3128            CompletionRequest::try_from(("gpt-test".to_string(), request)).expect("convert");
3129        let request_json = serde_json::to_value(&request).expect("serialize request");
3130
3131        assert_eq!(
3132            request_json.get("tool_choice"),
3133            Some(&json!({"type": "function", "name": "get_weather"}))
3134        );
3135    }
3136
3137    #[test]
3138    fn responses_function_tools_are_non_strict_by_default() {
3139        let tool = ResponsesToolDefinition::function(
3140            "get_weather",
3141            "Get the weather",
3142            weather_tool_definition().parameters,
3143        );
3144
3145        assert!(!tool.strict);
3146        assert_eq!(tool.parameters["required"], json!(["location"]));
3147        assert!(tool.parameters.get("additionalProperties").is_none());
3148
3149        let serialized = serde_json::to_value(tool).expect("tool should serialize");
3150        assert!(serialized.get("strict").is_none());
3151    }
3152
3153    #[test]
3154    fn responses_tool_definitions_accept_nullable_strict() {
3155        let cases = [
3156            (
3157                json!({
3158                    "type": "function",
3159                    "name": "get_weather",
3160                    "parameters": {}
3161                }),
3162                false,
3163            ),
3164            (
3165                json!({
3166                    "type": "function",
3167                    "name": "get_weather",
3168                    "parameters": {},
3169                    "strict": null
3170                }),
3171                false,
3172            ),
3173            (
3174                json!({
3175                    "type": "function",
3176                    "name": "get_weather",
3177                    "parameters": {},
3178                    "strict": false
3179                }),
3180                false,
3181            ),
3182            (
3183                json!({
3184                    "type": "function",
3185                    "name": "get_weather",
3186                    "parameters": {},
3187                    "strict": true
3188                }),
3189                true,
3190            ),
3191        ];
3192
3193        for (value, expected) in cases {
3194            let tool: ResponsesToolDefinition =
3195                serde_json::from_value(value).expect("tool definition should deserialize");
3196            assert_eq!(tool.strict, expected);
3197        }
3198    }
3199
3200    #[test]
3201    fn responses_strict_function_tools_sanitize_schema() {
3202        let tool = ResponsesToolDefinition::strict_function(
3203            "get_weather",
3204            "Get the weather",
3205            weather_tool_definition().parameters,
3206        );
3207
3208        assert!(tool.strict);
3209        assert_eq!(tool.parameters["additionalProperties"], json!(false));
3210        assert_eq!(tool.parameters["required"], json!(["location", "unit"]));
3211    }
3212
3213    fn request_with_preamble(preamble: &str) -> completion::CompletionRequest {
3214        completion::CompletionRequest {
3215            model: None,
3216            preamble: Some(preamble.to_string()),
3217            chat_history: crate::OneOrMany::one(message::Message::user("Hello")),
3218            documents: Vec::new(),
3219            tools: Vec::new(),
3220            temperature: None,
3221            max_tokens: None,
3222            tool_choice: None,
3223            additional_params: None,
3224            output_schema: None,
3225            record_telemetry_content: false,
3226        }
3227    }
3228
3229    fn system_only_request(system_text: &str) -> completion::CompletionRequest {
3230        completion::CompletionRequest {
3231            model: None,
3232            preamble: None,
3233            chat_history: crate::OneOrMany::one(completion::Message::system(system_text)),
3234            documents: Vec::new(),
3235            tools: Vec::new(),
3236            temperature: None,
3237            max_tokens: None,
3238            tool_choice: None,
3239            additional_params: None,
3240            output_schema: None,
3241            record_telemetry_content: false,
3242        }
3243    }
3244
3245    #[test]
3246    fn responses_request_uses_top_level_instructions_for_preamble_by_default() {
3247        let req = CompletionRequest::try_from((
3248            "gpt-4o-mini".to_string(),
3249            request_with_preamble("You are concise."),
3250        ))
3251        .expect("request should convert");
3252        let serialized = serde_json::to_value(&req).expect("request should serialize");
3253        let input = serialized["input"]
3254            .as_array()
3255            .expect("input should be array");
3256
3257        assert_eq!(serialized["instructions"], json!("You are concise."));
3258        assert_eq!(input.len(), 1);
3259        assert_eq!(input[0]["role"], "user");
3260    }
3261
3262    #[test]
3263    fn responses_request_drops_whitespace_only_preamble() {
3264        let req = CompletionRequest::try_from((
3265            "gpt-4o-mini".to_string(),
3266            request_with_preamble("  \n "),
3267        ))
3268        .expect("request should convert");
3269        let serialized = serde_json::to_value(&req).expect("request should serialize");
3270        let input = serialized["input"]
3271            .as_array()
3272            .expect("input should be array");
3273
3274        assert!(
3275            serialized.get("instructions").is_none(),
3276            "a whitespace-only preamble carries no content and is dropped"
3277        );
3278        assert_eq!(input.len(), 1);
3279        assert_eq!(input[0]["role"], "user");
3280    }
3281
3282    #[test]
3283    fn responses_request_lifts_system_messages_to_top_level_instructions_by_default() {
3284        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Hello")
3285            .preamble("System one".to_string())
3286            .message(completion::Message::system("System two"))
3287            .build();
3288
3289        let req = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3290            .expect("request should convert");
3291        let serialized = serde_json::to_value(&req).expect("request should serialize");
3292        let input = serialized["input"]
3293            .as_array()
3294            .expect("input should be array");
3295
3296        assert_eq!(
3297            serialized["instructions"],
3298            json!("System one\n\nSystem two")
3299        );
3300        assert_eq!(input.len(), 1);
3301        assert_eq!(input[0]["role"], "user");
3302    }
3303
3304    #[test]
3305    fn responses_request_with_only_system_messages_keeps_them_in_input() {
3306        let req = CompletionRequest::try_from((
3307            "gpt-4o-mini".to_string(),
3308            system_only_request("System only"),
3309        ))
3310        .expect("request conversion should succeed");
3311        let serialized = serde_json::to_value(&req).expect("request should serialize");
3312        let input = serialized["input"]
3313            .as_array()
3314            .expect("input should be array");
3315
3316        assert!(
3317            serialized.get("instructions").is_none(),
3318            "lifting a system-only history would leave input empty, so it stays in input"
3319        );
3320        assert_eq!(input.len(), 1);
3321        assert_eq!(input[0]["role"], "system");
3322        assert!(input[0].to_string().contains("System only"));
3323    }
3324
3325    #[test]
3326    fn responses_model_can_fallback_to_system_messages_in_input() {
3327        let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3328        let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3329            .with_system_instructions_as_messages();
3330
3331        let req = model
3332            .create_completion_request(request_with_preamble("You are concise."))
3333            .expect("request should convert");
3334        let serialized = serde_json::to_value(&req).expect("request should serialize");
3335        let input = serialized["input"]
3336            .as_array()
3337            .expect("input should be array");
3338
3339        assert!(serialized.get("instructions").is_none());
3340        assert_eq!(input.len(), 2);
3341        assert_eq!(input[0]["role"], "system");
3342        assert!(input[0].to_string().contains("You are concise."));
3343        assert_eq!(input[1]["role"], "user");
3344    }
3345
3346    #[test]
3347    fn responses_client_can_fallback_to_system_messages_in_input() {
3348        use crate::prelude::CompletionClient;
3349
3350        let client = crate::providers::openai::Client::new("dummy-key")
3351            .expect("client")
3352            .with_system_instructions_as_messages();
3353        let model = client.completion_model("gpt-4o-mini");
3354
3355        let req = model
3356            .create_completion_request(request_with_preamble("You are concise."))
3357            .expect("request should convert");
3358        let serialized = serde_json::to_value(&req).expect("request should serialize");
3359        let input = serialized["input"]
3360            .as_array()
3361            .expect("input should be array");
3362
3363        assert!(serialized.get("instructions").is_none());
3364        assert_eq!(input.len(), 2);
3365        assert_eq!(input[0]["role"], "system");
3366        assert!(input[0].to_string().contains("You are concise."));
3367        assert_eq!(input[1]["role"], "user");
3368    }
3369
3370    #[test]
3371    fn responses_model_can_lift_all_system_messages_via_placement() {
3372        let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3373        let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3374            .with_system_instructions_placement(SystemInstructionsPlacement::AllInstructions);
3375
3376        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "again")
3377            .preamble("System one".to_string())
3378            .message(completion::Message::user("hi"))
3379            .message(completion::Message::system("Mid-conversation instruction"))
3380            .build();
3381
3382        let req = model
3383            .create_completion_request(request)
3384            .expect("request should convert");
3385        let serialized = serde_json::to_value(&req).expect("request should serialize");
3386        let input = serialized["input"]
3387            .as_array()
3388            .expect("input should be array");
3389
3390        assert_eq!(
3391            serialized["instructions"],
3392            json!("System one\n\nMid-conversation instruction")
3393        );
3394        assert!(
3395            input.iter().all(|item| item["role"] != "system"),
3396            "AllInstructions should leave no system items in input: {input:?}"
3397        );
3398    }
3399
3400    #[test]
3401    fn responses_client_placement_survives_completions_api_round_trip() {
3402        use crate::prelude::CompletionClient;
3403
3404        let client = crate::providers::openai::Client::new("dummy-key")
3405            .expect("client")
3406            .with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
3407            .completions_api()
3408            .responses_api();
3409        let model = client.completion_model("gpt-4o-mini");
3410
3411        let req = model
3412            .create_completion_request(request_with_preamble("You are concise."))
3413            .expect("request should convert");
3414        let serialized = serde_json::to_value(&req).expect("request should serialize");
3415
3416        assert!(
3417            serialized.get("instructions").is_none(),
3418            "placement configured before completions_api() should survive responses_api()"
3419        );
3420        assert_eq!(serialized["input"][0]["role"], "system");
3421    }
3422
3423    #[test]
3424    fn all_instructions_system_only_input_reports_non_system_requirement() {
3425        let err = CompletionRequest::try_from(ResponsesRequestParams {
3426            model: "gpt-4o-mini".to_string(),
3427            request: system_only_request("System only"),
3428            system_instructions_placement: SystemInstructionsPlacement::AllInstructions,
3429        })
3430        .expect_err("system-only input should fail once every item is lifted");
3431
3432        assert!(
3433            err.to_string().contains("non-system item"),
3434            "error should explain that lifted system messages left input empty: {err}"
3435        );
3436    }
3437
3438    #[test]
3439    fn all_instructions_whitespace_only_system_input_reports_non_system_requirement() {
3440        let err = CompletionRequest::try_from(ResponsesRequestParams {
3441            model: "gpt-4o-mini".to_string(),
3442            request: system_only_request("   "),
3443            system_instructions_placement: SystemInstructionsPlacement::AllInstructions,
3444        })
3445        .expect_err("whitespace-only system input should fail once every item is lifted");
3446
3447        assert!(
3448            err.to_string().contains("non-system item"),
3449            "even when lifted system text is whitespace-only (so no `instructions` field is \
3450             produced), the error should explain that system messages were lifted: {err}"
3451        );
3452    }
3453
3454    #[test]
3455    fn responses_request_conversion_keeps_tools_non_strict_by_default() {
3456        let req = CompletionRequest::try_from(("gpt-4o-mini".to_string(), weather_tool_request()))
3457            .expect("request should convert");
3458
3459        let tool = &req.tools[0];
3460        assert!(!tool.strict);
3461        assert_eq!(tool.parameters["required"], json!(["location"]));
3462        assert!(tool.parameters.get("additionalProperties").is_none());
3463    }
3464
3465    #[test]
3466    fn responses_model_strict_tools_opt_in_sanitizes_all_function_tools() {
3467        let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3468        let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3469            .with_strict_tools()
3470            .with_tool(completion::ToolDefinition {
3471                name: "lookup".to_string(),
3472                description: "Look something up".to_string(),
3473                parameters: json!({
3474                    "type": "object",
3475                    "properties": {"q": {"type": "string"}}
3476                }),
3477            });
3478
3479        let mut request = weather_tool_request();
3480        request.additional_params = Some(json!({
3481            "tools": [{
3482                "type": "function",
3483                "name": "extra",
3484                "description": "An additional_params tool",
3485                "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
3486            }]
3487        }));
3488
3489        let req = model
3490            .create_completion_request(request)
3491            .expect("request should convert");
3492
3493        assert_eq!(req.tools.len(), 3);
3494        for tool in &req.tools {
3495            assert!(tool.strict, "{} should be strict", tool.name);
3496            assert_eq!(tool.parameters["additionalProperties"], json!(false));
3497        }
3498    }
3499
3500    #[test]
3501    fn responses_model_default_preserves_all_function_tools_as_constructed() {
3502        let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3503        let model = ResponsesCompletionModel::new(client, "gpt-4o-mini")
3504            .with_tool(weather_tool_definition());
3505
3506        let mut request = weather_tool_request();
3507        request.additional_params = Some(json!({
3508            "tools": [{
3509                "type": "function",
3510                "name": "extra",
3511                "description": "An additional_params tool",
3512                "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}
3513            }]
3514        }));
3515
3516        let req = model
3517            .create_completion_request(request)
3518            .expect("request should convert");
3519
3520        assert_eq!(req.tools.len(), 3);
3521        for tool in &req.tools {
3522            assert!(!tool.strict, "{} should not be strict", tool.name);
3523            assert!(tool.parameters.get("additionalProperties").is_none());
3524        }
3525    }
3526
3527    #[test]
3528    fn responses_explicit_strict_tool_stays_strict_on_default_model() {
3529        let client = crate::providers::openai::Client::new("dummy-key").expect("client");
3530        let model = ResponsesCompletionModel::new(client, "gpt-4o-mini").with_tool(
3531            ResponsesToolDefinition::strict_function(
3532                "lookup",
3533                "Look something up",
3534                json!({"type": "object", "properties": {"q": {"type": "string"}}}),
3535            ),
3536        );
3537
3538        let req = model
3539            .create_completion_request(weather_tool_request())
3540            .expect("request should convert");
3541
3542        assert!(!req.tools[0].strict);
3543        assert!(req.tools[1].strict);
3544        assert_eq!(
3545            req.tools[1].parameters["additionalProperties"],
3546            json!(false)
3547        );
3548    }
3549
3550    fn response_with_service_tier(service_tier: &str) -> Value {
3551        json!({
3552            "id": "resp_123",
3553            "object": "response",
3554            "created_at": 0,
3555            "status": "completed",
3556            "model": "gpt-5.4",
3557            "output": [],
3558            "service_tier": service_tier,
3559        })
3560    }
3561
3562    #[test]
3563    fn completion_response_deserializes_standard_service_tier() {
3564        let response: CompletionResponse =
3565            serde_json::from_value(response_with_service_tier("standard"))
3566                .expect("response should deserialize");
3567
3568        assert!(matches!(
3569            response.additional_parameters.service_tier,
3570            Some(OpenAIServiceTier::Standard)
3571        ));
3572    }
3573
3574    #[test]
3575    fn completion_response_deserializes_priority_service_tier() {
3576        let response: CompletionResponse =
3577            serde_json::from_value(response_with_service_tier("priority"))
3578                .expect("response should deserialize");
3579
3580        assert!(matches!(
3581            response.additional_parameters.service_tier,
3582            Some(OpenAIServiceTier::Priority)
3583        ));
3584    }
3585
3586    #[test]
3587    fn completion_response_preserves_unknown_service_tier() {
3588        let response: CompletionResponse =
3589            serde_json::from_value(response_with_service_tier("provider_experimental"))
3590                .expect("response should deserialize");
3591
3592        let Some(OpenAIServiceTier::Other(service_tier)) =
3593            response.additional_parameters.service_tier
3594        else {
3595            panic!("expected provider-specific service tier");
3596        };
3597
3598        assert_eq!(service_tier, "provider_experimental");
3599    }
3600
3601    #[test]
3602    fn responses_request_keeps_documents_after_lifted_system_messages() {
3603        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Prompt")
3604            .message(completion::Message::system("System prompt"))
3605            .message(completion::Message::user("Earlier user turn"))
3606            .message(completion::Message::assistant("Earlier assistant turn"))
3607            .document(test_document("doc1", "Document text."))
3608            .build();
3609
3610        let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3611            .expect("request conversion should succeed");
3612
3613        let serialized =
3614            serde_json::to_value(&responses_request).expect("request should serialize");
3615        let input = serialized["input"]
3616            .as_array()
3617            .expect("input should be an array");
3618
3619        assert_eq!(serialized["instructions"], json!("System prompt"));
3620        assert_eq!(input.len(), 4);
3621        assert_eq!(input[0]["role"], "user");
3622        assert!(
3623            input[0].to_string().contains("<file id: doc1>"),
3624            "document input should be first after system instructions are lifted: {input:?}"
3625        );
3626        assert_eq!(input[1]["role"], "user");
3627        assert!(
3628            input[1].to_string().contains("Earlier user turn"),
3629            "prior user history should follow document input: {input:?}"
3630        );
3631        assert_eq!(input[2]["role"], "assistant");
3632        assert!(
3633            input[2].to_string().contains("Earlier assistant turn"),
3634            "prior assistant history should follow prior user history: {input:?}"
3635        );
3636        assert_eq!(input[3]["role"], "user");
3637        assert!(
3638            input[3].to_string().contains("Prompt"),
3639            "prompt should remain last: {input:?}"
3640        );
3641    }
3642
3643    #[test]
3644    fn responses_direct_request_keeps_mid_conversation_system_messages_in_input() {
3645        let request = crate::completion::CompletionRequest {
3646            model: None,
3647            preamble: None,
3648            chat_history: crate::OneOrMany::many(vec![
3649                completion::Message::system("System prompt"),
3650                completion::Message::assistant("Earlier assistant turn"),
3651                completion::Message::system("Mid-conversation instruction"),
3652                completion::Message::user("Prompt"),
3653            ])
3654            .unwrap(),
3655            documents: vec![test_document("doc1", "Document text.")],
3656            tools: vec![],
3657            temperature: None,
3658            max_tokens: None,
3659            tool_choice: None,
3660            additional_params: None,
3661            output_schema: None,
3662            record_telemetry_content: false,
3663        };
3664
3665        let responses_request = CompletionRequest::try_from(("gpt-4o-mini".to_string(), request))
3666            .expect("request conversion should succeed");
3667
3668        let serialized =
3669            serde_json::to_value(&responses_request).expect("request should serialize");
3670        let input = serialized["input"]
3671            .as_array()
3672            .expect("input should be an array");
3673
3674        assert_eq!(
3675            serialized["instructions"],
3676            json!("System prompt"),
3677            "only the leading run of system messages should be lifted"
3678        );
3679        assert_eq!(input.len(), 4);
3680        assert_eq!(input[0]["role"], "user");
3681        assert!(
3682            input[0].to_string().contains("<file id: doc1>"),
3683            "document input should follow lifted system instructions: {input:?}"
3684        );
3685        assert_eq!(input[1]["role"], "assistant");
3686        assert_eq!(input[2]["role"], "system");
3687        assert!(
3688            input[2]
3689                .to_string()
3690                .contains("Mid-conversation instruction"),
3691            "mid-conversation system messages should keep their position: {input:?}"
3692        );
3693        assert_eq!(input[3]["role"], "user");
3694        assert_eq!(
3695            input
3696                .iter()
3697                .filter(|message| message.to_string().contains("<file id: doc1>"))
3698                .count(),
3699            1,
3700            "document input should appear exactly once: {input:?}"
3701        );
3702    }
3703
3704    #[test]
3705    fn service_tier_serializes_expected_strings() {
3706        let cases = [
3707            (OpenAIServiceTier::Auto, "auto"),
3708            (OpenAIServiceTier::Default, "default"),
3709            (OpenAIServiceTier::Flex, "flex"),
3710            (OpenAIServiceTier::Priority, "priority"),
3711            (OpenAIServiceTier::Standard, "standard"),
3712        ];
3713
3714        for (service_tier, expected) in cases {
3715            assert_eq!(
3716                serde_json::to_value(service_tier).expect("service tier should serialize"),
3717                json!(expected)
3718            );
3719        }
3720
3721        assert_eq!(
3722            serde_json::to_value(OpenAIServiceTier::Other(
3723                "provider_experimental".to_string()
3724            ))
3725            .expect("provider-specific service tier should serialize"),
3726            json!("provider_experimental")
3727        );
3728    }
3729
3730    #[test]
3731    fn responses_usage_token_usage_preserves_reasoning_tokens() {
3732        let usage = ResponsesUsage {
3733            input_tokens: 100,
3734            input_tokens_details: Some(InputTokensDetails { cached_tokens: 25 }),
3735            output_tokens: 50,
3736            output_tokens_details: Some(OutputTokensDetails {
3737                reasoning_tokens: 15,
3738            }),
3739            total_tokens: 150,
3740        };
3741
3742        let token_usage = usage.token_usage();
3743
3744        assert_eq!(token_usage.input_tokens, 100);
3745        assert_eq!(token_usage.cached_input_tokens, 25);
3746        assert_eq!(token_usage.output_tokens, 50);
3747        assert_eq!(token_usage.reasoning_tokens, 15);
3748        assert_eq!(token_usage.total_tokens, 150);
3749    }
3750
3751    #[test]
3752    fn responses_usage_deserializes_without_output_token_details() {
3753        let usage: ResponsesUsage = serde_json::from_value(json!({
3754            "input_tokens": 100,
3755            "input_tokens_details": {
3756                "cached_tokens": 25
3757            },
3758            "output_tokens": 50,
3759            "total_tokens": 150
3760        }))
3761        .expect("usage should deserialize when output token details are omitted");
3762
3763        assert!(usage.output_tokens_details.is_none());
3764
3765        let token_usage = usage.token_usage();
3766
3767        assert_eq!(token_usage.input_tokens, 100);
3768        assert_eq!(token_usage.cached_input_tokens, 25);
3769        assert_eq!(token_usage.output_tokens, 50);
3770        assert_eq!(token_usage.reasoning_tokens, 0);
3771        assert_eq!(token_usage.total_tokens, 150);
3772    }
3773
3774    #[test]
3775    fn completion_response_accepts_top_level_reasoning_string() {
3776        let response: CompletionResponse = serde_json::from_value(json!({
3777            "id": "resp_123",
3778            "object": "response",
3779            "created_at": 0,
3780            "status": "completed",
3781            "model": "Qwen/Qwen3-4B",
3782            "reasoning": "thinking through the answer",
3783            "usage": {
3784                "input_tokens": 1,
3785                "output_tokens": 2,
3786                "total_tokens": 3
3787            },
3788            "output": [{
3789                "type": "message",
3790                "id": "msg_123",
3791                "status": "completed",
3792                "role": "assistant",
3793                "content": [{
3794                    "type": "output_text",
3795                    "annotations": [],
3796                    "text": "done"
3797                }]
3798            }],
3799            "tools": []
3800        }))
3801        .expect("mistral.rs-style reasoning string should deserialize");
3802
3803        assert_eq!(
3804            response.provider_reasoning.as_deref(),
3805            Some("thinking through the answer")
3806        );
3807        assert_eq!(response.reasoning_metadata, None);
3808        assert_eq!(response.reasoning_context, None);
3809        assert_eq!(
3810            serde_json::to_value(&response).expect("response should serialize")["reasoning"],
3811            json!("thinking through the answer")
3812        );
3813
3814        let completion: completion::CompletionResponse<CompletionResponse> =
3815            response.try_into().expect("response should convert");
3816        let items = completion.choice.iter().collect::<Vec<_>>();
3817        assert!(matches!(
3818            items[0],
3819            completion::AssistantContent::Reasoning(_)
3820        ));
3821        assert!(matches!(items[1], completion::AssistantContent::Text(_)));
3822    }
3823
3824    #[test]
3825    fn completion_response_accepts_null_metadata() {
3826        let response: CompletionResponse = serde_json::from_value(json!({
3827            "id": "resp_123",
3828            "object": "response",
3829            "created_at": 0,
3830            "status": "completed",
3831            "model": "openai-compatible-model",
3832            "metadata": null,
3833            "output": [{
3834                "type": "message",
3835                "id": "msg_123",
3836                "status": "completed",
3837                "role": "assistant",
3838                "content": [{
3839                    "type": "output_text",
3840                    "annotations": [],
3841                    "text": "done"
3842                }]
3843            }],
3844            "tools": []
3845        }))
3846        .expect("response with null metadata should deserialize");
3847
3848        assert!(response.additional_parameters.metadata.is_empty());
3849    }
3850
3851    #[test]
3852    fn completion_response_accepts_reasoning_only_response() {
3853        let response: CompletionResponse = serde_json::from_value(json!({
3854            "id": "resp_123",
3855            "object": "response",
3856            "created_at": 0,
3857            "status": "completed",
3858            "model": "Qwen/Qwen3-4B",
3859            "reasoning": "thinking only",
3860            "usage": {
3861                "input_tokens": 1,
3862                "output_tokens": 2,
3863                "total_tokens": 3
3864            },
3865            "output": [],
3866            "tools": []
3867        }))
3868        .expect("reasoning-only response should deserialize");
3869
3870        let completion: completion::CompletionResponse<CompletionResponse> = response
3871            .try_into()
3872            .expect("reasoning-only response should convert");
3873        let items = completion.choice.iter().collect::<Vec<_>>();
3874
3875        assert_eq!(items.len(), 1);
3876        assert!(matches!(
3877            items[0],
3878            completion::AssistantContent::Reasoning(_)
3879        ));
3880    }
3881
3882    #[test]
3883    fn completion_response_rejects_empty_response_without_reasoning() {
3884        let response: CompletionResponse = serde_json::from_value(json!({
3885            "id": "resp_123",
3886            "object": "response",
3887            "created_at": 0,
3888            "status": "completed",
3889            "model": "Qwen/Qwen3-4B",
3890            "output": [],
3891            "tools": []
3892        }))
3893        .expect("empty response shape should deserialize");
3894
3895        let err = completion::CompletionResponse::<CompletionResponse>::try_from(response)
3896            .expect_err("empty response without reasoning should be rejected");
3897
3898        assert!(
3899            err.to_string()
3900                .contains("Response contained no message or tool call")
3901        );
3902    }
3903
3904    #[test]
3905    fn completion_response_preserves_context_without_treating_config_as_text() {
3906        let response: CompletionResponse = serde_json::from_value(json!({
3907            "id": "resp_123",
3908            "object": "response",
3909            "created_at": 0,
3910            "status": "completed",
3911            "model": "Qwen/Qwen3-4B",
3912            "reasoning": {
3913                "context": "all_turns",
3914                "effort": "high",
3915                "mode": "standard",
3916                "summary": null
3917            },
3918            "output": [{
3919                "type": "message",
3920                "id": "msg_123",
3921                "status": "completed",
3922                "role": "assistant",
3923                "content": [{
3924                    "type": "output_text",
3925                    "annotations": [],
3926                    "text": "done"
3927                }]
3928            }],
3929            "tools": []
3930        }))
3931        .expect("object-shaped reasoning should be tolerated");
3932
3933        assert!(response.provider_reasoning.is_none());
3934        assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
3935        assert_eq!(
3936            response.reasoning_metadata.as_ref(),
3937            json!({
3938                "context": "all_turns",
3939                "effort": "high",
3940                "mode": "standard",
3941                "summary": null
3942            })
3943            .as_object()
3944        );
3945        assert_eq!(
3946            serde_json::to_value(&response).expect("response should serialize")["reasoning"],
3947            json!({
3948                "context": "all_turns",
3949                "effort": "high",
3950                "mode": "standard",
3951                "summary": null
3952            })
3953        );
3954
3955        let completion: completion::CompletionResponse<CompletionResponse> =
3956            response.try_into().expect("response should convert");
3957        let items = completion.choice.iter().collect::<Vec<_>>();
3958        assert_eq!(items.len(), 1);
3959        assert!(matches!(items[0], completion::AssistantContent::Text(_)));
3960    }
3961
3962    #[test]
3963    fn completion_response_preserves_unknown_reasoning_metadata_and_nulls() {
3964        let metadata = json!({
3965            "context": "future_context",
3966            "effort": "ultra",
3967            "summary": null,
3968            "future_control": { "depth": 3 }
3969        });
3970        let response: CompletionResponse = serde_json::from_value(json!({
3971            "id": "resp_123",
3972            "object": "response",
3973            "created_at": 0,
3974            "status": "completed",
3975            "model": "gpt-future",
3976            "reasoning": metadata.clone(),
3977            "output": [],
3978            "tools": []
3979        }))
3980        .expect("unknown reasoning metadata should deserialize");
3981
3982        assert_eq!(
3983            response.reasoning_context.as_deref(),
3984            Some("future_context")
3985        );
3986        assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
3987        assert_eq!(
3988            serde_json::to_value(&response).expect("response should serialize")["reasoning"],
3989            metadata
3990        );
3991    }
3992
3993    #[test]
3994    fn completion_response_ignores_unsupported_reasoning_shapes() {
3995        for reasoning in [Value::Null, json!(["unexpected"]), json!(42), json!(true)] {
3996            let response: CompletionResponse = serde_json::from_value(json!({
3997                "id": "resp_123",
3998                "object": "response",
3999                "created_at": 0,
4000                "status": "completed",
4001                "model": "openai-compatible-model",
4002                "reasoning": reasoning,
4003                "output": [],
4004                "tools": []
4005            }))
4006            .expect("unsupported reasoning shapes should remain non-fatal");
4007
4008            assert_eq!(response.provider_reasoning, None);
4009            assert_eq!(response.reasoning_metadata, None);
4010            assert_eq!(response.reasoning_context, None);
4011            let serialized = serde_json::to_value(&response).expect("response should serialize");
4012            assert!(
4013                !serialized
4014                    .as_object()
4015                    .expect("response should serialize as an object")
4016                    .contains_key("reasoning")
4017            );
4018        }
4019    }
4020
4021    #[test]
4022    fn completion_response_reasoning_serialization_precedence_is_stable() {
4023        let mut response: CompletionResponse = serde_json::from_value(json!({
4024            "id": "resp_123",
4025            "object": "response",
4026            "created_at": 0,
4027            "status": "completed",
4028            "model": "gpt-5.6",
4029            "reasoning": { "context": "all_turns", "effort": "max" },
4030            "output": [],
4031            "tools": []
4032        }))
4033        .expect("reasoning metadata should deserialize");
4034
4035        response.reasoning_context = Some("current_turn".to_owned());
4036        response.additional_parameters.reasoning =
4037            Some(Reasoning::new().with_effort(ReasoningEffort::Low));
4038        let serialized = serde_json::to_string(&response).expect("response should serialize");
4039        assert_eq!(serialized.matches("\"reasoning\":").count(), 1);
4040        assert_eq!(
4041            serde_json::to_value(&response).expect("response should serialize")["reasoning"],
4042            json!({ "context": "all_turns", "effort": "max" })
4043        );
4044
4045        let metadata = response.reasoning_metadata.take();
4046        assert_eq!(
4047            serde_json::to_value(&response).expect("response should serialize")["reasoning"],
4048            json!({ "context": "current_turn" })
4049        );
4050
4051        response.reasoning_metadata = metadata;
4052        response.provider_reasoning = Some("compatible-provider text".to_owned());
4053        assert_eq!(
4054            serde_json::to_value(&response).expect("response should serialize")["reasoning"],
4055            json!("compatible-provider text")
4056        );
4057    }
4058
4059    fn request_with_reasoning_params(reasoning: Value) -> CompletionRequest {
4060        let mut request = request_with_preamble("You are concise.");
4061        request.additional_params = Some(json!({ "reasoning": reasoning }));
4062
4063        CompletionRequest::try_from(("gpt-5.6".to_string(), request))
4064            .expect("request with reasoning params should convert")
4065    }
4066
4067    #[test]
4068    fn reasoning_effort_max_survives_request_conversion() {
4069        let request = request_with_reasoning_params(json!({ "effort": "max" }));
4070        let serialized = serde_json::to_value(&request).expect("request should serialize");
4071
4072        assert_eq!(serialized["reasoning"], json!({ "effort": "max" }));
4073    }
4074
4075    #[test]
4076    fn reasoning_mode_pro_composes_with_independent_effort() {
4077        let request = request_with_reasoning_params(json!({ "effort": "high", "mode": "pro" }));
4078        let serialized = serde_json::to_value(&request).expect("request should serialize");
4079
4080        assert_eq!(
4081            serialized["reasoning"],
4082            json!({ "effort": "high", "mode": "pro" })
4083        );
4084    }
4085
4086    #[test]
4087    fn reasoning_context_values_survive_request_conversion() {
4088        for (context, wire_value) in [
4089            (ReasoningContext::Auto, "auto"),
4090            (ReasoningContext::AllTurns, "all_turns"),
4091            (ReasoningContext::CurrentTurn, "current_turn"),
4092        ] {
4093            let typed = serde_json::to_value(Reasoning::new().with_context(context))
4094                .expect("typed reasoning should serialize");
4095            assert_eq!(typed, json!({ "context": wire_value }));
4096
4097            let request = request_with_reasoning_params(json!({ "context": wire_value }));
4098            let serialized = serde_json::to_value(&request).expect("request should serialize");
4099            assert_eq!(serialized["reasoning"], json!({ "context": wire_value }));
4100        }
4101    }
4102
4103    #[test]
4104    fn reasoning_omits_unset_optional_fields() {
4105        let reasoning = serde_json::to_value(Reasoning::new().with_mode(ReasoningMode::Pro))
4106            .expect("reasoning should serialize");
4107
4108        assert_eq!(reasoning, json!({ "mode": "pro" }));
4109
4110        let reasoning = serde_json::to_value(
4111            Reasoning::new()
4112                .with_effort(ReasoningEffort::Max)
4113                .with_mode(ReasoningMode::Pro)
4114                .with_context(ReasoningContext::CurrentTurn)
4115                .with_summary_level(ReasoningSummaryLevel::Detailed),
4116        )
4117        .expect("reasoning should serialize");
4118
4119        assert_eq!(
4120            reasoning,
4121            json!({
4122                "effort": "max",
4123                "mode": "pro",
4124                "context": "current_turn",
4125                "summary": "detailed"
4126            })
4127        );
4128    }
4129
4130    #[test]
4131    fn completion_response_does_not_duplicate_structured_reasoning() {
4132        let response: CompletionResponse = serde_json::from_value(json!({
4133            "id": "resp_123",
4134            "object": "response",
4135            "created_at": 0,
4136            "status": "completed",
4137            "model": "gpt-5.4",
4138            "reasoning": "provider top-level text",
4139            "output": [{
4140                "type": "reasoning",
4141                "id": "rs_123",
4142                "summary": [{
4143                    "type": "summary_text",
4144                    "text": "structured summary"
4145                }]
4146            }, {
4147                "type": "message",
4148                "id": "msg_123",
4149                "status": "completed",
4150                "role": "assistant",
4151                "content": [{
4152                    "type": "output_text",
4153                    "annotations": [],
4154                    "text": "done"
4155                }]
4156            }],
4157            "tools": []
4158        }))
4159        .expect("response should deserialize");
4160
4161        let completion: completion::CompletionResponse<CompletionResponse> =
4162            response.try_into().expect("response should convert");
4163        let reasoning_count = completion
4164            .choice
4165            .iter()
4166            .filter(|item| matches!(item, completion::AssistantContent::Reasoning(_)))
4167            .count();
4168
4169        assert_eq!(reasoning_count, 1);
4170    }
4171
4172    #[test]
4173    fn idless_reasoning_is_skipped_when_converting_responses_history() {
4174        let assistant = message::Message::Assistant {
4175            id: Some("msg_123".to_string()),
4176            content: OneOrMany::one(message::AssistantContent::Reasoning(
4177                message::Reasoning::new("provider reasoning"),
4178            )),
4179        };
4180
4181        let converted = Vec::<Message>::try_from(assistant)
4182            .expect("idless reasoning should degrade gracefully");
4183
4184        assert!(converted.is_empty());
4185    }
4186
4187    #[test]
4188    fn idless_reasoning_only_is_skipped_without_empty_input_item() {
4189        let assistant = completion::Message::Assistant {
4190            id: None,
4191            content: OneOrMany::one(message::AssistantContent::Reasoning(
4192                message::Reasoning::new("provider reasoning"),
4193            )),
4194        };
4195
4196        let converted = Vec::<InputItem>::try_from(assistant)
4197            .expect("idless reasoning should degrade gracefully");
4198
4199        assert!(converted.is_empty());
4200    }
4201
4202    #[test]
4203    fn idless_reasoning_plus_text_preserves_text_for_responses_history() {
4204        let assistant = message::Message::Assistant {
4205            id: Some("msg_123".to_string()),
4206            content: OneOrMany::many(vec![
4207                message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
4208                message::AssistantContent::Text(Text::new("final answer")),
4209            ])
4210            .expect("assistant content should be non-empty"),
4211        };
4212
4213        let converted =
4214            Vec::<Message>::try_from(assistant).expect("assistant history should convert");
4215
4216        assert_eq!(converted.len(), 1);
4217        let Message::Assistant { content, .. } = &converted[0] else {
4218            panic!("expected assistant message");
4219        };
4220        assert!(matches!(
4221            content.first_ref(),
4222            AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
4223        ));
4224    }
4225
4226    #[test]
4227    fn completion_history_idless_reasoning_plus_text_preserves_text_input_item() {
4228        let assistant = completion::Message::Assistant {
4229            id: Some("msg_123".to_string()),
4230            content: OneOrMany::many(vec![
4231                message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
4232                message::AssistantContent::Text(Text::new("final answer")),
4233            ])
4234            .expect("assistant content should be non-empty"),
4235        };
4236
4237        let converted =
4238            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4239
4240        assert_eq!(converted.len(), 1);
4241        assert!(matches!(converted[0].role, Some(Role::Assistant)));
4242        let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
4243            panic!("expected assistant message input item");
4244        };
4245        assert!(matches!(
4246            content.first_ref(),
4247            AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
4248        ));
4249    }
4250
4251    #[test]
4252    fn assistant_text_without_idless_reasoning_replays_as_output_text() {
4253        let assistant = completion::Message::Assistant {
4254            id: Some("msg_123".to_string()),
4255            content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
4256        };
4257
4258        let converted =
4259            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4260
4261        assert_eq!(converted.len(), 1);
4262        let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
4263            panic!("expected assistant message input item");
4264        };
4265        assert!(matches!(
4266            content.first_ref(),
4267            AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
4268        ));
4269    }
4270
4271    #[test]
4272    fn idless_completion_assistant_text_replays_as_easy_input_message() {
4273        let assistant = completion::Message::Assistant {
4274            id: None,
4275            content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
4276        };
4277
4278        let converted =
4279            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4280
4281        assert_eq!(converted.len(), 1);
4282        assert!(matches!(converted[0].role, Some(Role::Assistant)));
4283        let InputContent::Message(Message::AssistantInput { content, .. }) = &converted[0].input
4284        else {
4285            panic!("expected assistant input message item");
4286        };
4287        assert_eq!(content, "final answer");
4288
4289        let serialized =
4290            serde_json::to_value(&converted[0]).expect("input item should serialize to JSON");
4291        assert_eq!(serialized["type"], json!("message"));
4292        assert_eq!(serialized["role"], json!("assistant"));
4293        assert_eq!(serialized["content"], json!("final answer"));
4294        assert!(serialized.get("id").is_none());
4295        assert!(serialized.get("status").is_none());
4296    }
4297
4298    #[test]
4299    fn idless_message_assistant_text_replays_as_easy_input_message() {
4300        let assistant = message::Message::Assistant {
4301            id: None,
4302            content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
4303        };
4304
4305        let converted =
4306            Vec::<Message>::try_from(assistant).expect("assistant history should convert");
4307
4308        assert_eq!(converted.len(), 1);
4309        let Message::AssistantInput { content, .. } = &converted[0] else {
4310            panic!("expected assistant input message");
4311        };
4312        assert_eq!(content, "final answer");
4313
4314        let serialized = serde_json::to_value(&converted[0])
4315            .expect("assistant message should serialize to JSON");
4316        assert_eq!(serialized["role"], json!("assistant"));
4317        assert_eq!(serialized["content"], json!("final answer"));
4318        assert!(serialized.get("id").is_none());
4319        assert!(serialized.get("status").is_none());
4320    }
4321
4322    #[test]
4323    fn structured_reasoning_with_id_still_converts_for_responses_history() {
4324        let assistant = message::Message::Assistant {
4325            id: Some("msg_123".to_string()),
4326            content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
4327                id: Some("rs_123".to_string()),
4328                content: vec![message::ReasoningContent::Summary(
4329                    "structured summary".to_string(),
4330                )],
4331            })),
4332        };
4333
4334        let converted =
4335            Vec::<Message>::try_from(assistant).expect("structured reasoning should still convert");
4336
4337        assert_eq!(converted.len(), 1);
4338        let Message::Assistant { content, .. } = &converted[0] else {
4339            panic!("expected assistant message");
4340        };
4341        assert!(matches!(
4342            content.first_ref(),
4343            AssistantContentType::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
4344        ));
4345    }
4346
4347    #[test]
4348    fn structured_reasoning_with_id_still_converts_to_input_item() {
4349        let assistant = completion::Message::Assistant {
4350            id: Some("msg_123".to_string()),
4351            content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
4352                id: Some("rs_123".to_string()),
4353                content: vec![message::ReasoningContent::Summary(
4354                    "structured summary".to_string(),
4355                )],
4356            })),
4357        };
4358
4359        let converted =
4360            Vec::<InputItem>::try_from(assistant).expect("structured reasoning should convert");
4361
4362        assert_eq!(converted.len(), 1);
4363        assert!(converted[0].role.is_none());
4364        assert!(matches!(
4365            &converted[0].input,
4366            InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
4367        ));
4368    }
4369
4370    #[test]
4371    fn assistant_reasoning_text_tool_call_convert_in_responses_replay_order() {
4372        let assistant = completion::Message::Assistant {
4373            id: Some("msg_123".to_string()),
4374            content: OneOrMany::many(vec![
4375                message::AssistantContent::Reasoning(message::Reasoning {
4376                    id: Some("rs_123".to_string()),
4377                    content: vec![message::ReasoningContent::Summary(
4378                        "structured summary".to_string(),
4379                    )],
4380                }),
4381                message::AssistantContent::Text(Text::new("final answer")),
4382                message::AssistantContent::tool_call_with_call_id(
4383                    "fc_123",
4384                    "call_123".to_string(),
4385                    "lookup",
4386                    json!({"query": "rig"}),
4387                ),
4388            ])
4389            .expect("assistant content should be non-empty"),
4390        };
4391
4392        let converted =
4393            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
4394
4395        assert_eq!(converted.len(), 3);
4396        assert!(converted[0].role.is_none());
4397        assert!(matches!(
4398            &converted[0].input,
4399            InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
4400        ));
4401
4402        assert!(matches!(converted[1].role, Some(Role::Assistant)));
4403        let InputContent::Message(Message::Assistant { content, id, .. }) = &converted[1].input
4404        else {
4405            panic!("expected assistant output message");
4406        };
4407        assert_eq!(id, "msg_123");
4408        assert!(matches!(
4409            content.first_ref(),
4410            AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. }))
4411                if text == "final answer"
4412        ));
4413
4414        assert!(converted[2].role.is_none());
4415        let InputContent::FunctionCall(OutputFunctionCall {
4416            id, call_id, name, ..
4417        }) = &converted[2].input
4418        else {
4419            panic!("expected function call input item");
4420        };
4421        assert_eq!(id, "fc_123");
4422        assert_eq!(call_id, "call_123");
4423        assert_eq!(name, "lookup");
4424    }
4425
4426    #[test]
4427    fn mocked_second_turn_request_omits_unreplayable_reasoning() {
4428        let request = crate::completion::CompletionRequest {
4429            model: None,
4430            preamble: Some("You are concise.".to_string()),
4431            chat_history: OneOrMany::many(vec![
4432                completion::Message::User {
4433                    content: OneOrMany::one(message::UserContent::Text(Text::new(
4434                        "Think briefly, then answer.",
4435                    ))),
4436                },
4437                completion::Message::Assistant {
4438                    id: Some("msg_123".to_string()),
4439                    content: OneOrMany::many(vec![
4440                        message::AssistantContent::Reasoning(message::Reasoning::new(
4441                            "provider reasoning",
4442                        )),
4443                        message::AssistantContent::Text(Text::new("final answer")),
4444                    ])
4445                    .expect("assistant content should be non-empty"),
4446                },
4447                completion::Message::Assistant {
4448                    id: None,
4449                    content: OneOrMany::many(vec![
4450                        message::AssistantContent::Reasoning(message::Reasoning::new(
4451                            "provider reasoning only",
4452                        )),
4453                        message::AssistantContent::Text(Text::new("")),
4454                    ])
4455                    .expect("assistant content should be non-empty"),
4456                },
4457                completion::Message::User {
4458                    content: OneOrMany::one(message::UserContent::Text(Text::new(
4459                        "/no_think Reply with exactly: OK",
4460                    ))),
4461                },
4462            ])
4463            .expect("history should be non-empty"),
4464            documents: Vec::new(),
4465            tools: Vec::new(),
4466            temperature: None,
4467            max_tokens: Some(64),
4468            tool_choice: None,
4469            additional_params: None,
4470            output_schema: None,
4471            record_telemetry_content: false,
4472        };
4473
4474        let request = CompletionRequest::try_from(("Qwen/Qwen3-4B".to_string(), request))
4475            .expect("request should convert");
4476        let value = serde_json::to_value(&request).expect("request should serialize");
4477        let input = value["input"]
4478            .as_array()
4479            .expect("mocked multi-turn request should serialize input as an array");
4480
4481        assert!(!input.iter().any(|item| {
4482            item.get("type") == Some(&json!("reasoning")) && item.get("id").is_none()
4483        }));
4484        assert!(!input.iter().any(|item| {
4485            item.get("role") == Some(&json!("assistant"))
4486                && item
4487                    .get("content")
4488                    .and_then(Value::as_array)
4489                    .is_some_and(Vec::is_empty)
4490        }));
4491
4492        let assistant_items = input
4493            .iter()
4494            .filter(|item| item.get("role") == Some(&json!("assistant")))
4495            .collect::<Vec<_>>();
4496
4497        assert_eq!(assistant_items.len(), 1);
4498        assert_eq!(assistant_items[0]["content"][0]["type"], "output_text");
4499        assert_eq!(assistant_items[0]["content"][0]["text"], "final answer");
4500    }
4501
4502    #[test]
4503    fn responses_usage_add_preserves_rhs_details_when_lhs_details_are_absent() {
4504        let lhs = ResponsesUsage {
4505            input_tokens: 10,
4506            input_tokens_details: None,
4507            output_tokens: 20,
4508            output_tokens_details: None,
4509            total_tokens: 30,
4510        };
4511        let rhs = ResponsesUsage {
4512            input_tokens: 3,
4513            input_tokens_details: Some(InputTokensDetails { cached_tokens: 2 }),
4514            output_tokens: 5,
4515            output_tokens_details: Some(OutputTokensDetails {
4516                reasoning_tokens: 4,
4517            }),
4518            total_tokens: 8,
4519        };
4520
4521        let usage = lhs + rhs;
4522        let token_usage = usage.token_usage();
4523
4524        assert_eq!(token_usage.input_tokens, 13);
4525        assert_eq!(token_usage.cached_input_tokens, 2);
4526        assert_eq!(token_usage.output_tokens, 25);
4527        assert_eq!(token_usage.reasoning_tokens, 4);
4528        assert_eq!(token_usage.total_tokens, 38);
4529    }
4530
4531    #[test]
4532    fn file_id_document_serializes_as_input_file_content() {
4533        let message = message::Message::User {
4534            content: OneOrMany::one(message::UserContent::Document(message::Document {
4535                data: DocumentSourceKind::FileId("file_abc".to_string()),
4536                media_type: None,
4537                additional_params: None,
4538            })),
4539        };
4540
4541        let converted: Vec<Message> = message.try_into().expect("conversion should succeed");
4542        let Message::User { content, .. } = &converted[0] else {
4543            panic!("expected user message");
4544        };
4545
4546        let json = serde_json::to_value(content.first_ref()).expect("serialize content");
4547
4548        assert_eq!(json["type"], "input_file");
4549        assert_eq!(json["file_id"], "file_abc");
4550        assert!(json.get("file_data").is_none());
4551        assert!(json.get("file_url").is_none());
4552    }
4553
4554    #[test]
4555    fn file_id_document_serializes_as_input_item_content() {
4556        let message = completion::Message::User {
4557            content: OneOrMany::one(message::UserContent::Document(message::Document {
4558                data: DocumentSourceKind::FileId("file_abc".to_string()),
4559                media_type: None,
4560                additional_params: None,
4561            })),
4562        };
4563
4564        let converted: Vec<InputItem> = message.try_into().expect("conversion should succeed");
4565        let json = serde_json::to_value(&converted[0]).expect("serialize input item");
4566
4567        assert_eq!(json["type"], "message");
4568        assert_eq!(json["role"], "user");
4569        assert_eq!(json["content"][0]["type"], "input_file");
4570        assert_eq!(json["content"][0]["file_id"], "file_abc");
4571        assert!(json["content"][0].get("file_data").is_none());
4572        assert!(json["content"][0].get("file_url").is_none());
4573    }
4574
4575    #[tokio::test]
4576    async fn responses_completion_http_non_success_preserves_status_and_body() {
4577        use crate::client::CompletionClient;
4578        use crate::completion::CompletionModel;
4579        use crate::providers::openai::Client;
4580        use crate::test_utils::RecordingHttpClient;
4581
4582        let body = r#"{"error":{"message":"bad image","type":"invalid_request_error","code":"invalid_value"}}"#;
4583        let http_client =
4584            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
4585        let client = Client::builder()
4586            .api_key("test-key")
4587            .http_client(http_client)
4588            .build()
4589            .expect("build client");
4590        let model = client.completion_model("gpt-4o-mini");
4591        let request = model.completion_request("hello").build();
4592
4593        let error = model
4594            .completion(request)
4595            .await
4596            .expect_err("completion should fail with non-success status");
4597
4598        assert!(matches!(error, CompletionError::HttpError(_)));
4599        assert_eq!(
4600            error.provider_response_status(),
4601            Some(http::StatusCode::BAD_REQUEST)
4602        );
4603        assert_eq!(error.provider_response_body(), Some(body));
4604        let json = error
4605            .provider_response_json()
4606            .expect("raw body should be valid JSON")
4607            .expect("parsed JSON should be present");
4608        assert_eq!(json["error"]["code"], "invalid_value");
4609    }
4610
4611    #[test]
4612    fn output_unknown_preserves_hosted_tool_payload() {
4613        let item = json!({
4614            "type": "web_search_call",
4615            "id": "ws_001",
4616            "status": "completed",
4617            "action": { "type": "search", "queries": ["rig framework"] },
4618        });
4619
4620        let output: Output =
4621            serde_json::from_value(item.clone()).expect("unknown output should deserialize");
4622
4623        let Output::Unknown(value) = output else {
4624            panic!("expected Output::Unknown for an unmodeled item type");
4625        };
4626        assert_eq!(value, item);
4627    }
4628
4629    #[test]
4630    fn output_unknown_round_trips_value_equal() {
4631        let item = json!({
4632            "type": "file_search_call",
4633            "id": "fs_007",
4634            "status": "in_progress",
4635            "queries": ["lifecycle"],
4636        });
4637
4638        let output: Output =
4639            serde_json::from_value(item.clone()).expect("unknown output should deserialize");
4640        let serialized = serde_json::to_value(&output).expect("unknown output should serialize");
4641
4642        assert_eq!(serialized, item);
4643    }
4644
4645    #[test]
4646    fn output_known_variant_with_bad_body_errors() {
4647        // A recognized `type` tag with a malformed body must still error rather
4648        // than silently degrading to `Output::Unknown`.
4649        let malformed = json!({
4650            "type": "function_call",
4651            "id": "call_1",
4652            // missing `arguments`, `call_id`, `name`
4653        });
4654
4655        let result: Result<Output, _> = serde_json::from_value(malformed);
4656        assert!(result.is_err());
4657    }
4658
4659    #[test]
4660    fn completion_response_with_unknown_output_keeps_usage() {
4661        // Guards the original reason the catch-all exists: an unknown item must
4662        // not break decoding of the whole response or drop token usage.
4663        let response = json!({
4664            "id": "resp_123",
4665            "object": "response",
4666            "created_at": 0,
4667            "status": "completed",
4668            "model": "gpt-5.4",
4669            "output": [
4670                {
4671                    "type": "web_search_call",
4672                    "id": "ws_001",
4673                    "status": "completed",
4674                },
4675                {
4676                    "type": "message",
4677                    "id": "msg_1",
4678                    "role": "assistant",
4679                    "status": "completed",
4680                    "content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
4681                },
4682            ],
4683            "usage": {
4684                "input_tokens": 100,
4685                "input_tokens_details": { "cached_tokens": 25 },
4686                "output_tokens": 50,
4687                "output_tokens_details": { "reasoning_tokens": 15 },
4688                "total_tokens": 150,
4689            },
4690        });
4691
4692        let response: CompletionResponse =
4693            serde_json::from_value(response).expect("response should deserialize");
4694
4695        assert!(matches!(response.output.first(), Some(Output::Unknown(_))));
4696        let usage = response.usage.expect("usage should be present");
4697        assert_eq!(usage.total_tokens, 150);
4698    }
4699
4700    #[test]
4701    fn output_known_variant_round_trips_value_equal() {
4702        // The hand-written Serialize must reproduce the modeled wire shape, so a
4703        // decoded known item re-serializes value-equal to what it came from
4704        // (guards the `function_call` arm, including its stringified `arguments`).
4705        // The item ID uses the provider-native `fc_` prefix; other IDs are
4706        // intentionally dropped on serialization (see `OutputFunctionCall::id`).
4707        let item = json!({
4708            "type": "function_call",
4709            "id": "fc_1",
4710            "arguments": "{}",
4711            "call_id": "c1",
4712            "name": "search",
4713            "status": "completed",
4714        });
4715
4716        let output: Output =
4717            serde_json::from_value(item.clone()).expect("known output should deserialize");
4718        assert!(matches!(output, Output::FunctionCall(_)));
4719
4720        let serialized = serde_json::to_value(&output).expect("known output should serialize");
4721        assert_eq!(serialized, item);
4722    }
4723
4724    #[test]
4725    fn output_reasoning_round_trips_value_equal() {
4726        // Highest-value parity guard: the `Reasoning` struct variant threads its
4727        // fields by hand in *both* directions. Populated `encrypted_content` /
4728        // `status` (the `#[serde(default)]` optionals) must survive
4729        // serialize -> deserialize unchanged — catching a dropped field or a
4730        // forgotten `reasoning` dispatch arm (which would degrade to `Unknown`).
4731        let original = Output::Reasoning {
4732            id: "reasoning_1".to_string(),
4733            summary: vec![ReasoningSummary::SummaryText {
4734                text: "weighing options".to_string(),
4735            }],
4736            content: vec!["private reasoning".to_string()],
4737            encrypted_content: Some("ENCRYPTED".to_string()),
4738            status: Some(ToolStatus::Completed),
4739        };
4740
4741        let value = serde_json::to_value(&original).expect("reasoning should serialize");
4742        let round_tripped: Output =
4743            serde_json::from_value(value).expect("reasoning should deserialize");
4744
4745        assert_eq!(round_tripped, original);
4746    }
4747
4748    #[test]
4749    fn output_reasoning_conversion_omits_empty_encrypted_content() {
4750        let output = Output::Reasoning {
4751            id: "reasoning_1".to_string(),
4752            summary: vec![],
4753            content: vec!["visible reasoning".to_string()],
4754            encrypted_content: Some(String::new()),
4755            status: Some(ToolStatus::Completed),
4756        };
4757
4758        let converted = Vec::<completion::AssistantContent>::from(output);
4759
4760        assert_eq!(converted.len(), 1);
4761        let completion::AssistantContent::Reasoning(reasoning) = &converted[0] else {
4762            panic!("expected reasoning output");
4763        };
4764        assert_eq!(reasoning.id.as_deref(), Some("reasoning_1"));
4765        assert_eq!(reasoning.content.len(), 1);
4766        assert!(matches!(
4767            reasoning.content.first(),
4768            Some(message::ReasoningContent::Text { text, .. })
4769                if text == "visible reasoning"
4770        ));
4771    }
4772
4773    #[test]
4774    fn output_reasoning_conversion_preserves_non_empty_encrypted_content() {
4775        let output = Output::Reasoning {
4776            id: "reasoning_1".to_string(),
4777            summary: vec![],
4778            content: vec![],
4779            encrypted_content: Some("ciphertext".to_string()),
4780            status: Some(ToolStatus::Completed),
4781        };
4782
4783        let converted = Vec::<completion::AssistantContent>::from(output);
4784
4785        assert_eq!(converted.len(), 1);
4786        let completion::AssistantContent::Reasoning(reasoning) = &converted[0] else {
4787            panic!("expected reasoning output");
4788        };
4789        assert_eq!(
4790            reasoning.content,
4791            vec![message::ReasoningContent::Encrypted(
4792                "ciphertext".to_string()
4793            )]
4794        );
4795    }
4796
4797    #[test]
4798    fn output_reasoning_none_optionals_serialize_as_explicit_null() {
4799        // Wire-anchored complement to the round-trip test: with `None`
4800        // optionals, the keys must still be emitted as explicit `null` (the
4801        // derived behavior this hand-written serde replaced has no
4802        // `skip_serializing_if`). Guards against a future refactor silently
4803        // dropping the keys and changing the wire shape.
4804        let value = serde_json::to_value(Output::Reasoning {
4805            id: "reasoning_1".to_string(),
4806            summary: vec![],
4807            content: vec![],
4808            encrypted_content: None,
4809            status: None,
4810        })
4811        .expect("reasoning should serialize");
4812
4813        assert_eq!(value["type"], "reasoning");
4814        assert_eq!(value["encrypted_content"], Value::Null);
4815        assert_eq!(value["status"], Value::Null);
4816        assert!(value.get("encrypted_content").is_some());
4817        assert!(value.get("status").is_some());
4818    }
4819
4820    #[test]
4821    fn output_message_round_trips_value_equal() {
4822        // Wire-anchored serialize check for the `message` arm (only
4823        // `function_call` was anchored): a decoded message item re-serializes
4824        // value-equal to the input, tag included.
4825        let item = json!({
4826            "type": "message",
4827            "id": "msg_1",
4828            "role": "assistant",
4829            "status": "completed",
4830            "content": [ { "type": "output_text", "text": "hello", "annotations": [] } ],
4831        });
4832
4833        let output: Output =
4834            serde_json::from_value(item.clone()).expect("message item should deserialize");
4835        assert!(matches!(output, Output::Message(_)));
4836
4837        let serialized = serde_json::to_value(&output).expect("message should serialize");
4838        assert_eq!(serialized, item);
4839    }
4840
4841    #[test]
4842    fn each_known_tag_decodes_to_its_modeled_variant() {
4843        // Guards every modeled dispatch arm: a well-formed item for each known
4844        // `type` must decode to its specific variant, never to `Unknown`. Adding
4845        // an `Output` variant without a matching deserialize arm fails here
4846        // instead of silently routing real items to `Unknown`.
4847        let message: Output = serde_json::from_value(json!({
4848            "type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
4849            "content": [ { "type": "output_text", "text": "hi", "annotations": [] } ],
4850        }))
4851        .expect("message item should decode");
4852        assert!(matches!(message, Output::Message(_)));
4853
4854        let function_call: Output = serde_json::from_value(json!({
4855            "type": "function_call", "id": "call_1", "arguments": "{}",
4856            "call_id": "c1", "name": "f", "status": "completed",
4857        }))
4858        .expect("function_call item should decode");
4859        assert!(matches!(function_call, Output::FunctionCall(_)));
4860
4861        let reasoning: Output =
4862            serde_json::from_value(json!({ "type": "reasoning", "id": "r1", "summary": [] }))
4863                .expect("reasoning item should decode");
4864        assert!(matches!(reasoning, Output::Reasoning { .. }));
4865    }
4866
4867    #[test]
4868    fn output_without_usable_type_tag_decodes_to_unknown() {
4869        // An absent or non-string `type` is itself unmodeled, so it is captured
4870        // verbatim as `Unknown` rather than erroring.
4871        for item in [
4872            json!({ "id": "x", "note": "no type field" }),
4873            json!({ "type": 7, "id": "x" }),
4874        ] {
4875            let output: Output =
4876                serde_json::from_value(item.clone()).expect("should decode to Unknown");
4877            assert_eq!(output, Output::Unknown(item));
4878        }
4879    }
4880
4881    // Regression tests for issue #1429: `file_url` and `filename` are mutually
4882    // exclusive on OpenAI's Responses API (400 `mutually_exclusive_parameters`),
4883    // so URL-backed PDFs must not carry the hardcoded `filename`. PR #1432
4884    // fixed the `TryFrom<message::Message> for Vec<Message>` conversion; these
4885    // tests also cover the `TryFrom<crate::completion::Message> for
4886    // Vec<InputItem>` path that `CompletionModel::completion()` requests
4887    // actually go through.
4888    //
4889    // See <https://platform.openai.com/docs/guides/pdf-files> for the
4890    // `input_file` content part and its `file_url` / `file_data` / `file_id`
4891    // input variants.
4892
4893    const PDF_URL: &str = "https://example.com/resume.pdf";
4894
4895    fn url_pdf_message() -> message::Message {
4896        message::Message::User {
4897            content: OneOrMany::one(message::UserContent::document_url(
4898                PDF_URL,
4899                Some(message::DocumentMediaType::PDF),
4900            )),
4901        }
4902    }
4903
4904    /// Recursively collect every JSON object with `"type": "input_file"`.
4905    fn find_input_files(value: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
4906        match value {
4907            serde_json::Value::Object(map) => {
4908                if map.get("type").and_then(|t| t.as_str()) == Some("input_file") {
4909                    out.push(value.clone());
4910                }
4911                map.values().for_each(|v| find_input_files(v, out));
4912            }
4913            serde_json::Value::Array(items) => {
4914                items.iter().for_each(|v| find_input_files(v, out));
4915            }
4916            _ => {}
4917        }
4918    }
4919
4920    fn sole_input_file(value: &serde_json::Value) -> serde_json::Value {
4921        let mut found = Vec::new();
4922        find_input_files(value, &mut found);
4923        assert_eq!(
4924            found.len(),
4925            1,
4926            "expected exactly one input_file item in {value:#}"
4927        );
4928        found.pop().unwrap()
4929    }
4930
4931    fn assert_url_only_input_file(input_file: &serde_json::Value) {
4932        assert_eq!(
4933            input_file.get("file_url").and_then(|v| v.as_str()),
4934            Some(PDF_URL),
4935            "URL PDF should carry file_url: {input_file:#}"
4936        );
4937        assert_eq!(
4938            input_file.get("filename"),
4939            None,
4940            "filename must be absent for URL PDFs (issue #1429): {input_file:#}"
4941        );
4942        assert_eq!(
4943            input_file.get("file_data"),
4944            None,
4945            "file_data must be absent for URL PDFs: {input_file:#}"
4946        );
4947    }
4948
4949    #[test]
4950    fn url_pdf_via_input_item_path_omits_filename() {
4951        let items = Vec::<InputItem>::try_from(url_pdf_message())
4952            .expect("URL PDF should convert to input items");
4953        let json = serde_json::to_value(&items).expect("input items should serialize");
4954        assert_url_only_input_file(&sole_input_file(&json));
4955    }
4956
4957    #[test]
4958    fn url_pdf_in_full_completion_request_omits_filename() {
4959        let core_request = crate::completion::CompletionRequest {
4960            model: None,
4961            preamble: None,
4962            chat_history: OneOrMany::one(url_pdf_message()),
4963            documents: Vec::new(),
4964            tools: Vec::new(),
4965            temperature: None,
4966            max_tokens: None,
4967            tool_choice: None,
4968            additional_params: None,
4969            output_schema: None,
4970            record_telemetry_content: false,
4971        };
4972
4973        let request = CompletionRequest::try_from(("gpt-4o".to_string(), core_request))
4974            .expect("request should convert");
4975        let json = serde_json::to_value(&request).expect("request should serialize");
4976        assert_url_only_input_file(&sole_input_file(&json));
4977    }
4978
4979    #[test]
4980    fn url_pdf_via_vec_message_path_omits_filename() {
4981        let messages = Vec::<Message>::try_from(url_pdf_message())
4982            .expect("URL PDF should convert to messages");
4983        let json = serde_json::to_value(&messages).expect("messages should serialize");
4984        assert_url_only_input_file(&sole_input_file(&json));
4985    }
4986
4987    #[test]
4988    fn base64_pdf_via_input_item_path_keeps_filename() {
4989        let input = message::Message::User {
4990            content: OneOrMany::one(message::UserContent::Document(message::Document {
4991                data: DocumentSourceKind::base64("dGVzdA=="),
4992                media_type: Some(message::DocumentMediaType::PDF),
4993                additional_params: None,
4994            })),
4995        };
4996
4997        let items =
4998            Vec::<InputItem>::try_from(input).expect("base64 PDF should convert to input items");
4999        let json = serde_json::to_value(&items).expect("input items should serialize");
5000        let input_file = sole_input_file(&json);
5001
5002        assert_eq!(
5003            input_file.get("file_data").and_then(|v| v.as_str()),
5004            Some("data:application/pdf;base64,dGVzdA=="),
5005            "base64 PDF should carry file_data: {input_file:#}"
5006        );
5007        assert_eq!(
5008            input_file.get("filename").and_then(|v| v.as_str()),
5009            Some("document.pdf"),
5010            "base64 PDF should keep the default filename: {input_file:#}"
5011        );
5012        assert_eq!(
5013            input_file.get("file_url"),
5014            None,
5015            "base64 PDF should not carry file_url: {input_file:#}"
5016        );
5017    }
5018}