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