Skip to main content

rig_core/providers/openai/responses_api/
mod.rs

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