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::completion::ToolChoice;
18use super::responses_api::streaming::StreamingCompletionResponse;
19use crate::completion::{CompletionError, GetTokenUsage};
20use crate::http_client;
21use crate::http_client::HttpClientExt;
22use crate::json_utils;
23use crate::message::{
24    AudioMediaType, Document, DocumentMediaType, DocumentSourceKind, ImageDetail, MessageError,
25    MimeType, Text,
26};
27use crate::one_or_many::string_or_one_or_many;
28
29use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
30use crate::{OneOrMany, completion, message};
31use serde::{Deserialize, Deserializer, Serialize, Serializer};
32use serde_json::{Map, Value};
33use tracing::{Instrument, Level, enabled, info_span};
34
35use std::convert::Infallible;
36use std::ops::Add;
37use std::str::FromStr;
38
39pub mod streaming;
40#[cfg(all(not(target_family = "wasm"), feature = "websocket"))]
41pub mod websocket;
42
43/// The completion request type for OpenAI's Response API: <https://platform.openai.com/docs/api-reference/responses/create>
44/// Intended to be derived from [`crate::completion::request::CompletionRequest`].
45#[derive(Debug, Deserialize, Serialize, Clone)]
46pub struct CompletionRequest {
47    /// Message inputs
48    pub input: OneOrMany<InputItem>,
49    /// The model name
50    pub model: String,
51    /// Instructions (also referred to as preamble, although in other APIs this would be the "system prompt")
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub instructions: Option<String>,
54    /// The maximum number of output tokens.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub max_output_tokens: Option<u64>,
57    /// Toggle to true for streaming responses.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub stream: Option<bool>,
60    /// The temperature. Set higher (up to a max of 1.0) for more creative responses.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub temperature: Option<f64>,
63    /// Whether the LLM should be forced to use a tool before returning a response.
64    /// If none provided, the default option is "auto".
65    #[serde(skip_serializing_if = "Option::is_none")]
66    tool_choice: Option<ToolChoice>,
67    /// The tools you want to use. This supports both function tools and hosted tools
68    /// such as `web_search`, `file_search`, and `computer_use`.
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub tools: Vec<ResponsesToolDefinition>,
71    /// Additional parameters
72    #[serde(flatten)]
73    pub additional_parameters: AdditionalParameters,
74}
75
76impl CompletionRequest {
77    pub fn with_structured_outputs<S>(mut self, schema_name: S, schema: serde_json::Value) -> Self
78    where
79        S: Into<String>,
80    {
81        self.additional_parameters.text = Some(TextConfig::structured_output(schema_name, schema));
82
83        self
84    }
85
86    pub fn with_reasoning(mut self, reasoning: Reasoning) -> Self {
87        self.additional_parameters.reasoning = Some(reasoning);
88
89        self
90    }
91
92    /// Adds a provider-native hosted tool (e.g. `web_search`, `file_search`, `computer_use`)
93    /// to the request. These tools are executed by OpenAI's infrastructure, not by Rig's
94    /// agent loop.
95    pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
96        self.tools.push(tool.into());
97        self
98    }
99
100    /// Adds multiple provider-native hosted tools to the request. These tools are executed
101    /// by OpenAI's infrastructure, not by Rig's agent loop.
102    pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
103    where
104        I: IntoIterator<Item = Tool>,
105        Tool: Into<ResponsesToolDefinition>,
106    {
107        self.tools.extend(tools.into_iter().map(Into::into));
108        self
109    }
110}
111
112/// An input item for [`CompletionRequest`].
113#[derive(Debug, Deserialize, Clone)]
114pub struct InputItem {
115    /// The role of an input item/message.
116    /// Input messages should be Some(Role::User), and output messages should be Some(Role::Assistant).
117    /// Everything else should be None.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    role: Option<Role>,
120    /// The input content itself.
121    #[serde(flatten)]
122    input: InputContent,
123}
124
125impl Serialize for InputItem {
126    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127    where
128        S: serde::Serializer,
129    {
130        let mut value = serde_json::to_value(&self.input).map_err(serde::ser::Error::custom)?;
131        let map = value.as_object_mut().ok_or_else(|| {
132            serde::ser::Error::custom("Input content must serialize to an object")
133        })?;
134
135        if let Some(role) = &self.role
136            && !map.contains_key("role")
137        {
138            map.insert(
139                "role".to_string(),
140                serde_json::to_value(role).map_err(serde::ser::Error::custom)?,
141            );
142        }
143
144        value.serialize(serializer)
145    }
146}
147
148impl InputItem {
149    pub fn system_message(content: impl Into<String>) -> Self {
150        Self {
151            role: Some(Role::System),
152            input: InputContent::Message(Message::System {
153                content: OneOrMany::one(SystemContent::InputText {
154                    text: content.into(),
155                }),
156                name: None,
157            }),
158        }
159    }
160
161    pub(crate) fn system_text(&self) -> Option<String> {
162        match &self.input {
163            InputContent::Message(Message::System { content, .. }) => Some(
164                content
165                    .iter()
166                    .map(|item| match item {
167                        SystemContent::InputText { text } => text.as_str(),
168                    })
169                    .collect::<Vec<_>>()
170                    .join("\n"),
171            ),
172            _ => None,
173        }
174    }
175}
176
177/// Message roles. Used by OpenAI Responses API to determine who created a given message.
178#[derive(Debug, Deserialize, Serialize, Clone)]
179#[serde(rename_all = "lowercase")]
180pub enum Role {
181    User,
182    Assistant,
183    System,
184}
185
186/// The type of content used in an [`InputItem`]. Additionally holds data for each type of input content.
187#[derive(Debug, Deserialize, Serialize, Clone)]
188#[serde(tag = "type", rename_all = "snake_case")]
189pub enum InputContent {
190    Message(Message),
191    Reasoning(OpenAIReasoning),
192    FunctionCall(OutputFunctionCall),
193    FunctionCallOutput(ToolResult),
194}
195
196#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
197pub struct OpenAIReasoning {
198    id: String,
199    pub summary: Vec<ReasoningSummary>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub encrypted_content: Option<String>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub status: Option<ToolStatus>,
204}
205
206#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
207#[serde(tag = "type", rename_all = "snake_case")]
208pub enum ReasoningSummary {
209    SummaryText { text: String },
210}
211
212impl ReasoningSummary {
213    fn new(input: &str) -> Self {
214        Self::SummaryText {
215            text: input.to_string(),
216        }
217    }
218
219    pub fn text(&self) -> String {
220        let ReasoningSummary::SummaryText { text } = self;
221        text.clone()
222    }
223}
224
225/// A tool result.
226#[derive(Debug, Deserialize, Serialize, Clone)]
227pub struct ToolResult {
228    /// The call ID of a tool (this should be linked to the call ID for a tool call, otherwise an error will be received)
229    call_id: String,
230    /// The result of a tool call.
231    output: String,
232    /// The status of a tool call (if used in a completion request, this should always be Completed)
233    status: ToolStatus,
234}
235
236impl From<Message> for InputItem {
237    fn from(value: Message) -> Self {
238        match value {
239            Message::User { .. } => Self {
240                role: Some(Role::User),
241                input: InputContent::Message(value),
242            },
243            Message::Assistant { ref content, .. } => {
244                let role = if content
245                    .iter()
246                    .any(|x| matches!(x, AssistantContentType::Reasoning(_)))
247                {
248                    None
249                } else {
250                    Some(Role::Assistant)
251                };
252                Self {
253                    role,
254                    input: InputContent::Message(value),
255                }
256            }
257            Message::System { .. } => Self {
258                role: Some(Role::System),
259                input: InputContent::Message(value),
260            },
261            Message::ToolResult {
262                tool_call_id,
263                output,
264            } => Self {
265                role: None,
266                input: InputContent::FunctionCallOutput(ToolResult {
267                    call_id: tool_call_id,
268                    output,
269                    status: ToolStatus::Completed,
270                }),
271            },
272        }
273    }
274}
275
276impl TryFrom<crate::completion::Message> for Vec<InputItem> {
277    type Error = CompletionError;
278
279    fn try_from(value: crate::completion::Message) -> Result<Self, Self::Error> {
280        match value {
281            crate::completion::Message::System { content } => Ok(vec![InputItem {
282                role: Some(Role::System),
283                input: InputContent::Message(Message::System {
284                    content: OneOrMany::one(content.into()),
285                    name: None,
286                }),
287            }]),
288            crate::completion::Message::User { content } => {
289                let mut items = Vec::new();
290
291                for user_content in content {
292                    match user_content {
293                        crate::message::UserContent::Text(Text { text, .. }) => {
294                            items.push(InputItem {
295                                role: Some(Role::User),
296                                input: InputContent::Message(Message::User {
297                                    content: OneOrMany::one(UserContent::InputText { text }),
298                                    name: None,
299                                }),
300                            });
301                        }
302                        crate::message::UserContent::ToolResult(
303                            crate::completion::message::ToolResult {
304                                call_id,
305                                content: tool_content,
306                                ..
307                            },
308                        ) => {
309                            for tool_result_content in tool_content {
310                                let crate::completion::message::ToolResultContent::Text(Text {
311                                    text,
312                                    ..
313                                }) = tool_result_content
314                                else {
315                                    return Err(CompletionError::ProviderError(
316                                        "This thing only supports text!".to_string(),
317                                    ));
318                                };
319                                // let output = serde_json::from_str(&text)?;
320                                items.push(InputItem {
321                                    role: None,
322                                    input: InputContent::FunctionCallOutput(ToolResult {
323                                        call_id: require_call_id(call_id.clone(), "Tool result")?,
324                                        output: text,
325                                        status: ToolStatus::Completed,
326                                    }),
327                                });
328                            }
329                        }
330                        crate::message::UserContent::Document(Document {
331                            data: DocumentSourceKind::FileId(file_id),
332                            ..
333                        }) => items.push(InputItem {
334                            role: Some(Role::User),
335                            input: InputContent::Message(Message::User {
336                                content: OneOrMany::one(UserContent::InputFile {
337                                    file_id: Some(file_id),
338                                    file_data: None,
339                                    file_url: None,
340                                    filename: None,
341                                }),
342                                name: None,
343                            }),
344                        }),
345                        crate::message::UserContent::Document(Document {
346                            data,
347                            media_type: Some(DocumentMediaType::PDF),
348                            ..
349                        }) => {
350                            let (file_data, file_url) = match data {
351                                DocumentSourceKind::Base64(data) => {
352                                    (Some(format!("data:application/pdf;base64,{data}")), None)
353                                }
354                                DocumentSourceKind::Url(url) => (None, Some(url)),
355                                DocumentSourceKind::Raw(_) => {
356                                    return Err(CompletionError::RequestError(
357                                        "Raw file data not supported, encode as base64 first"
358                                            .into(),
359                                    ));
360                                }
361                                doc => {
362                                    return Err(CompletionError::RequestError(
363                                        format!("Unsupported document type: {doc}").into(),
364                                    ));
365                                }
366                            };
367
368                            items.push(InputItem {
369                                role: Some(Role::User),
370                                input: InputContent::Message(Message::User {
371                                    content: OneOrMany::one(UserContent::InputFile {
372                                        file_id: None,
373                                        file_data,
374                                        file_url,
375                                        filename: Some("document.pdf".to_string()),
376                                    }),
377                                    name: None,
378                                }),
379                            })
380                        }
381                        crate::message::UserContent::Document(Document {
382                            data:
383                                DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text),
384                            ..
385                        }) => items.push(InputItem {
386                            role: Some(Role::User),
387                            input: InputContent::Message(Message::User {
388                                content: OneOrMany::one(UserContent::InputText { text }),
389                                name: None,
390                            }),
391                        }),
392                        crate::message::UserContent::Image(crate::message::Image {
393                            data,
394                            media_type,
395                            detail,
396                            ..
397                        }) => {
398                            let url = match data {
399                                DocumentSourceKind::Base64(data) => {
400                                    let media_type = if let Some(media_type) = media_type {
401                                        media_type.to_mime_type().to_string()
402                                    } else {
403                                        String::new()
404                                    };
405                                    format!("data:{media_type};base64,{data}")
406                                }
407                                DocumentSourceKind::Url(url) => url,
408                                DocumentSourceKind::Raw(_) => {
409                                    return Err(CompletionError::RequestError(
410                                        "Raw file data not supported, encode as base64 first"
411                                            .into(),
412                                    ));
413                                }
414                                doc => {
415                                    return Err(CompletionError::RequestError(
416                                        format!("Unsupported document type: {doc}").into(),
417                                    ));
418                                }
419                            };
420                            items.push(InputItem {
421                                role: Some(Role::User),
422                                input: InputContent::Message(Message::User {
423                                    content: OneOrMany::one(UserContent::InputImage {
424                                        image_url: url,
425                                        detail: detail.unwrap_or_default(),
426                                    }),
427                                    name: None,
428                                }),
429                            });
430                        }
431                        message => {
432                            return Err(CompletionError::ProviderError(format!(
433                                "Unsupported message: {message:?}"
434                            )));
435                        }
436                    }
437                }
438
439                Ok(items)
440            }
441            crate::completion::Message::Assistant { id, content } => {
442                let mut reasoning_items = Vec::new();
443                let mut other_items = Vec::new();
444                let content = content.into_iter().collect::<Vec<_>>();
445                let has_unreplayable_reasoning = content.iter().any(|assistant_content| {
446                    matches!(
447                        assistant_content,
448                        crate::message::AssistantContent::Reasoning(reasoning)
449                            if reasoning.id.is_none()
450                    )
451                });
452                let cannot_replay_as_provider_output = id.is_none() || has_unreplayable_reasoning;
453
454                for assistant_content in content {
455                    match assistant_content {
456                        crate::message::AssistantContent::Text(Text { text, .. }) => {
457                            if text.is_empty() {
458                                continue;
459                            }
460                            let text = if cannot_replay_as_provider_output {
461                                AssistantContent::InputText { text }
462                            } else {
463                                AssistantContent::OutputText(Text::new(text))
464                            };
465                            other_items.push(InputItem {
466                                role: Some(Role::Assistant),
467                                input: InputContent::Message(Message::Assistant {
468                                    content: OneOrMany::one(AssistantContentType::Text(text)),
469                                    id: id.clone().unwrap_or_default(),
470                                    name: None,
471                                    status: ToolStatus::Completed,
472                                }),
473                            });
474                        }
475                        crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
476                            id: tool_id,
477                            call_id,
478                            function,
479                            ..
480                        }) => {
481                            other_items.push(InputItem {
482                                role: None,
483                                input: InputContent::FunctionCall(OutputFunctionCall {
484                                    arguments: function.arguments,
485                                    call_id: require_call_id(call_id, "Assistant tool call")?,
486                                    id: tool_id,
487                                    name: function.name,
488                                    status: ToolStatus::Completed,
489                                }),
490                            });
491                        }
492                        crate::message::AssistantContent::Reasoning(reasoning) => {
493                            let openai_reasoning = openai_reasoning_from_core(&reasoning)
494                                .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
495                            if let Some(openai_reasoning) = openai_reasoning {
496                                reasoning_items.push(InputItem {
497                                    role: None,
498                                    input: InputContent::Reasoning(openai_reasoning),
499                                });
500                            }
501                        }
502                        crate::message::AssistantContent::Image(_) => {
503                            return Err(CompletionError::ProviderError(
504                                "Assistant image content is not supported in OpenAI Responses API"
505                                    .to_string(),
506                            ));
507                        }
508                    }
509                }
510
511                let mut items = reasoning_items;
512                items.extend(other_items);
513                Ok(items)
514            }
515        }
516    }
517}
518
519impl From<OneOrMany<String>> for Vec<ReasoningSummary> {
520    fn from(value: OneOrMany<String>) -> Self {
521        value.iter().map(|x| ReasoningSummary::new(x)).collect()
522    }
523}
524
525fn require_call_id(call_id: Option<String>, context: &str) -> Result<String, CompletionError> {
526    call_id.ok_or_else(|| {
527        CompletionError::RequestError(
528            format!("{context} `call_id` is required for OpenAI Responses API").into(),
529        )
530    })
531}
532
533fn openai_reasoning_from_core(
534    reasoning: &crate::message::Reasoning,
535) -> Result<Option<OpenAIReasoning>, MessageError> {
536    let Some(id) = reasoning.id.clone() else {
537        return Ok(None);
538    };
539
540    let mut summary = Vec::new();
541    let mut encrypted_content = None;
542    for content in &reasoning.content {
543        match content {
544            crate::message::ReasoningContent::Text { text, .. }
545            | crate::message::ReasoningContent::Summary(text) => {
546                summary.push(ReasoningSummary::new(text));
547            }
548            // OpenAI reasoning input has one opaque payload field; preserve either
549            // encrypted or redacted blocks there, preferring the first one seen.
550            crate::message::ReasoningContent::Encrypted(data)
551            | crate::message::ReasoningContent::Redacted { data } => {
552                encrypted_content.get_or_insert_with(|| data.clone());
553            }
554        }
555    }
556
557    Ok(Some(OpenAIReasoning {
558        id,
559        summary,
560        encrypted_content,
561        status: None,
562    }))
563}
564
565fn optional_reasoning_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
566where
567    D: Deserializer<'de>,
568{
569    Ok(
570        match Option::<serde_json::Value>::deserialize(deserializer)? {
571            Some(serde_json::Value::String(reasoning)) => Some(reasoning),
572            _ => None,
573        },
574    )
575}
576
577/// The definition of a tool response, repurposed for OpenAI's Responses API.
578#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
579pub struct ResponsesToolDefinition {
580    /// The type of tool.
581    #[serde(rename = "type")]
582    pub kind: String,
583    /// Tool name
584    #[serde(default, skip_serializing_if = "String::is_empty")]
585    pub name: String,
586    /// Parameters - this should be a JSON schema. Tools should additionally ensure an "additionalParameters" field has been added with the value set to false, as this is required if using OpenAI's strict mode (enabled by default).
587    #[serde(default, skip_serializing_if = "is_json_null")]
588    pub parameters: serde_json::Value,
589    /// Whether to use strict mode. Enabled by default as it allows for improved efficiency.
590    #[serde(default, skip_serializing_if = "is_false")]
591    pub strict: bool,
592    /// Tool description.
593    #[serde(default, skip_serializing_if = "String::is_empty")]
594    pub description: String,
595    /// Additional provider-specific configuration for hosted tools.
596    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
597    pub config: Map<String, Value>,
598}
599
600fn is_json_null(value: &Value) -> bool {
601    value.is_null()
602}
603
604fn is_false(value: &bool) -> bool {
605    !value
606}
607
608impl ResponsesToolDefinition {
609    /// Creates a function tool definition.
610    pub fn function(
611        name: impl Into<String>,
612        description: impl Into<String>,
613        mut parameters: serde_json::Value,
614    ) -> Self {
615        super::sanitize_schema(&mut parameters);
616
617        Self {
618            kind: "function".to_string(),
619            name: name.into(),
620            parameters,
621            strict: true,
622            description: description.into(),
623            config: Map::new(),
624        }
625    }
626
627    /// Creates a hosted tool definition for an arbitrary hosted tool type.
628    pub fn hosted(kind: impl Into<String>) -> Self {
629        Self {
630            kind: kind.into(),
631            name: String::new(),
632            parameters: Value::Null,
633            strict: false,
634            description: String::new(),
635            config: Map::new(),
636        }
637    }
638
639    /// Creates a hosted `web_search` tool definition.
640    pub fn web_search() -> Self {
641        Self::hosted("web_search")
642    }
643
644    /// Creates a hosted `file_search` tool definition.
645    pub fn file_search() -> Self {
646        Self::hosted("file_search")
647    }
648
649    /// Creates a hosted `computer_use` tool definition.
650    pub fn computer_use() -> Self {
651        Self::hosted("computer_use")
652    }
653
654    /// Adds hosted-tool configuration fields.
655    pub fn with_config(mut self, key: impl Into<String>, value: Value) -> Self {
656        self.config.insert(key.into(), value);
657        self
658    }
659
660    fn normalize(mut self) -> Self {
661        if self.kind == "function" {
662            super::sanitize_schema(&mut self.parameters);
663            self.strict = true;
664        }
665        self
666    }
667}
668
669impl From<completion::ToolDefinition> for ResponsesToolDefinition {
670    fn from(value: completion::ToolDefinition) -> Self {
671        let completion::ToolDefinition {
672            name,
673            parameters,
674            description,
675        } = value;
676
677        Self::function(name, description, parameters)
678    }
679}
680
681/// Token usage.
682/// 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.
683#[derive(Clone, Debug, Serialize, Deserialize)]
684pub struct ResponsesUsage {
685    /// Input tokens
686    pub input_tokens: u64,
687    /// In-depth detail on input tokens (cached tokens)
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub input_tokens_details: Option<InputTokensDetails>,
690    /// Output tokens
691    pub output_tokens: u64,
692    /// In-depth detail on output tokens (reasoning tokens)
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub output_tokens_details: Option<OutputTokensDetails>,
695    /// Total tokens used (for a given prompt)
696    pub total_tokens: u64,
697}
698
699impl ResponsesUsage {
700    /// Create a new ResponsesUsage instance
701    pub(crate) fn new() -> Self {
702        Self {
703            input_tokens: 0,
704            input_tokens_details: Some(InputTokensDetails::new()),
705            output_tokens: 0,
706            output_tokens_details: Some(OutputTokensDetails::new()),
707            total_tokens: 0,
708        }
709    }
710}
711
712impl GetTokenUsage for ResponsesUsage {
713    fn token_usage(&self) -> Option<crate::completion::Usage> {
714        Some(crate::completion::Usage {
715            input_tokens: self.input_tokens,
716            output_tokens: self.output_tokens,
717            total_tokens: self.total_tokens,
718            cached_input_tokens: self
719                .input_tokens_details
720                .as_ref()
721                .map(|details| details.cached_tokens)
722                .unwrap_or(0),
723            cache_creation_input_tokens: 0,
724            tool_use_prompt_tokens: 0,
725            reasoning_tokens: self
726                .output_tokens_details
727                .as_ref()
728                .map(|details| details.reasoning_tokens)
729                .unwrap_or(0),
730        })
731    }
732}
733
734impl Add for ResponsesUsage {
735    type Output = Self;
736
737    fn add(self, rhs: Self) -> Self::Output {
738        let input_tokens = self.input_tokens + rhs.input_tokens;
739        let input_tokens_details = match (self.input_tokens_details, rhs.input_tokens_details) {
740            (Some(lhs), Some(rhs)) => Some(lhs + rhs),
741            (Some(lhs), None) => Some(lhs),
742            (None, Some(rhs)) => Some(rhs),
743            (None, None) => None,
744        };
745        let output_tokens = self.output_tokens + rhs.output_tokens;
746        let output_tokens_details = match (self.output_tokens_details, rhs.output_tokens_details) {
747            (Some(lhs), Some(rhs)) => Some(lhs + rhs),
748            (Some(lhs), None) => Some(lhs),
749            (None, Some(rhs)) => Some(rhs),
750            (None, None) => None,
751        };
752        let total_tokens = self.total_tokens + rhs.total_tokens;
753        Self {
754            input_tokens,
755            input_tokens_details,
756            output_tokens,
757            output_tokens_details,
758            total_tokens,
759        }
760    }
761}
762
763/// In-depth details on input tokens.
764#[derive(Clone, Debug, Serialize, Deserialize)]
765pub struct InputTokensDetails {
766    /// Cached tokens from OpenAI
767    pub cached_tokens: u64,
768}
769
770impl InputTokensDetails {
771    pub(crate) fn new() -> Self {
772        Self { cached_tokens: 0 }
773    }
774}
775
776impl Add for InputTokensDetails {
777    type Output = Self;
778    fn add(self, rhs: Self) -> Self::Output {
779        Self {
780            cached_tokens: self.cached_tokens + rhs.cached_tokens,
781        }
782    }
783}
784
785/// In-depth details on output tokens.
786#[derive(Clone, Debug, Serialize, Deserialize)]
787pub struct OutputTokensDetails {
788    /// Reasoning tokens
789    pub reasoning_tokens: u64,
790}
791
792impl OutputTokensDetails {
793    pub(crate) fn new() -> Self {
794        Self {
795            reasoning_tokens: 0,
796        }
797    }
798}
799
800impl Add for OutputTokensDetails {
801    type Output = Self;
802    fn add(self, rhs: Self) -> Self::Output {
803        Self {
804            reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
805        }
806    }
807}
808
809/// Occasionally, when using OpenAI's Responses API you may get an incomplete response. This struct holds the reason as to why it happened.
810#[derive(Clone, Debug, Default, Serialize, Deserialize)]
811pub struct IncompleteDetailsReason {
812    /// The reason for an incomplete [`CompletionResponse`].
813    pub reason: String,
814}
815
816/// A response error from OpenAI's Response API.
817#[derive(Clone, Debug, Default, Serialize, Deserialize)]
818pub struct ResponseError {
819    /// Error code
820    pub code: String,
821    /// Error message
822    pub message: String,
823}
824
825/// A response object as an enum (ensures type validation)
826#[derive(Clone, Debug, Deserialize, Serialize)]
827#[serde(rename_all = "snake_case")]
828pub enum ResponseObject {
829    Response,
830}
831
832/// The response status as an enum (ensures type validation)
833#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
834#[serde(rename_all = "snake_case")]
835pub enum ResponseStatus {
836    InProgress,
837    Completed,
838    Failed,
839    Cancelled,
840    Queued,
841    Incomplete,
842}
843
844/// Attempt to try and create a `NewCompletionRequest` from a model name and [`crate::completion::CompletionRequest`]
845impl TryFrom<(String, crate::completion::CompletionRequest)> for CompletionRequest {
846    type Error = CompletionError;
847    fn try_from(
848        (model, mut req): (String, crate::completion::CompletionRequest),
849    ) -> Result<Self, Self::Error> {
850        let model = req.model.clone().unwrap_or(model);
851        let input = {
852            let mut partial_history = vec![];
853            if let Some(docs) = req.normalized_documents() {
854                partial_history.push(docs);
855            }
856            partial_history.extend(req.chat_history);
857
858            // Initialize full history with preamble (or empty if non-existent)
859            // Some "Responses API compatible" providers don't support `instructions` field
860            // so we need to add a system message until further notice
861            let mut full_history: Vec<InputItem> = if let Some(content) = req.preamble {
862                vec![InputItem::system_message(content)]
863            } else {
864                Vec::new()
865            };
866
867            for history_item in partial_history {
868                full_history.extend(<Vec<InputItem>>::try_from(history_item)?);
869            }
870
871            full_history
872        };
873
874        let input = OneOrMany::many(input).map_err(|_| {
875            CompletionError::RequestError(
876                "OpenAI Responses request input must contain at least one item".into(),
877            )
878        })?;
879
880        let mut additional_params_payload = req.additional_params.take().unwrap_or(Value::Null);
881        let stream = match &additional_params_payload {
882            Value::Bool(stream) => Some(*stream),
883            Value::Object(map) => map.get("stream").and_then(Value::as_bool),
884            _ => None,
885        };
886
887        let mut additional_tools = Vec::new();
888        if let Some(additional_params_map) = additional_params_payload.as_object_mut() {
889            if let Some(raw_tools) = additional_params_map.remove("tools") {
890                additional_tools = serde_json::from_value::<Vec<ResponsesToolDefinition>>(
891                    raw_tools,
892                )
893                .map_err(|err| {
894                    CompletionError::RequestError(
895                        format!(
896                            "Invalid OpenAI Responses tools payload in additional_params: {err}"
897                        )
898                        .into(),
899                    )
900                })?;
901            }
902            additional_params_map.remove("stream");
903        }
904
905        if additional_params_payload.is_boolean() {
906            additional_params_payload = Value::Null;
907        }
908
909        additional_tools = additional_tools
910            .into_iter()
911            .map(ResponsesToolDefinition::normalize)
912            .collect();
913
914        let mut additional_parameters = if additional_params_payload.is_null() {
915            // If there's no additional parameters, initialise an empty object
916            AdditionalParameters::default()
917        } else {
918            serde_json::from_value::<AdditionalParameters>(additional_params_payload).map_err(
919                |err| {
920                    CompletionError::RequestError(
921                        format!("Invalid OpenAI Responses additional_params payload: {err}").into(),
922                    )
923                },
924            )?
925        };
926        if additional_parameters.reasoning.is_some() {
927            let include = additional_parameters.include.get_or_insert_with(Vec::new);
928            if !include
929                .iter()
930                .any(|item| matches!(item, Include::ReasoningEncryptedContent))
931            {
932                include.push(Include::ReasoningEncryptedContent);
933            }
934        }
935
936        // Apply output_schema as structured output if not already configured via additional_params
937        if additional_parameters.text.is_none()
938            && let Some(schema) = req.output_schema
939        {
940            let name = schema
941                .as_object()
942                .and_then(|o| o.get("title"))
943                .and_then(|v| v.as_str())
944                .unwrap_or("response_schema")
945                .to_string();
946            let mut schema_value = schema.to_value();
947            super::sanitize_schema(&mut schema_value);
948            additional_parameters.text = Some(TextConfig::structured_output(name, schema_value));
949        }
950
951        let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
952        let mut tools: Vec<ResponsesToolDefinition> = req
953            .tools
954            .into_iter()
955            .map(ResponsesToolDefinition::from)
956            .collect();
957        tools.append(&mut additional_tools);
958
959        Ok(Self {
960            input,
961            model,
962            instructions: None, // is currently None due to lack of support in compliant providers
963            max_output_tokens: req.max_tokens,
964            stream,
965            tool_choice,
966            tools,
967            temperature: req.temperature,
968            additional_parameters,
969        })
970    }
971}
972
973/// The completion model struct for OpenAI's response API.
974#[doc(hidden)]
975#[derive(Clone)]
976pub struct GenericResponsesCompletionModel<Ext = super::OpenAIResponsesExt, H = reqwest::Client> {
977    /// The OpenAI client
978    pub(crate) client: crate::client::Client<Ext, H>,
979    /// Name of the model (e.g.: gpt-3.5-turbo-1106)
980    pub model: String,
981    /// Model-level default tools that are always added to outgoing requests.
982    pub tools: Vec<ResponsesToolDefinition>,
983}
984
985/// The completion model struct for OpenAI's Responses API.
986///
987/// This preserves the historical public generic shape where the first generic
988/// parameter is the HTTP client type.
989pub type ResponsesCompletionModel<H = reqwest::Client> =
990    GenericResponsesCompletionModel<super::OpenAIResponsesExt, H>;
991
992impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
993where
994    crate::client::Client<Ext, H>: HttpClientExt + Clone + std::fmt::Debug + 'static,
995    Ext: crate::client::Provider + Clone + 'static,
996    H: Clone + Default + std::fmt::Debug + 'static,
997{
998    /// Creates a new [`ResponsesCompletionModel`].
999    pub fn new(client: crate::client::Client<Ext, H>, model: impl Into<String>) -> Self {
1000        Self {
1001            client,
1002            model: model.into(),
1003            tools: Vec::new(),
1004        }
1005    }
1006
1007    pub fn with_model(client: crate::client::Client<Ext, H>, model: &str) -> Self {
1008        Self {
1009            client,
1010            model: model.to_string(),
1011            tools: Vec::new(),
1012        }
1013    }
1014
1015    /// Adds a default tool to all requests from this model.
1016    pub fn with_tool(mut self, tool: impl Into<ResponsesToolDefinition>) -> Self {
1017        self.tools.push(tool.into());
1018        self
1019    }
1020
1021    /// Adds default tools to all requests from this model.
1022    pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
1023    where
1024        I: IntoIterator<Item = Tool>,
1025        Tool: Into<ResponsesToolDefinition>,
1026    {
1027        self.tools.extend(tools.into_iter().map(Into::into));
1028        self
1029    }
1030
1031    /// Attempt to create a completion request from [`crate::completion::CompletionRequest`].
1032    pub(crate) fn create_completion_request(
1033        &self,
1034        completion_request: crate::completion::CompletionRequest,
1035    ) -> Result<CompletionRequest, CompletionError> {
1036        let mut req = CompletionRequest::try_from((self.model.clone(), completion_request))?;
1037        req.tools.extend(self.tools.clone());
1038
1039        Ok(req)
1040    }
1041}
1042
1043impl<T> GenericResponsesCompletionModel<super::OpenAIResponsesExt, T>
1044where
1045    T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
1046{
1047    /// Use the Completions API instead of Responses.
1048    pub fn completions_api(self) -> crate::providers::openai::completion::CompletionModel<T> {
1049        super::completion::CompletionModel::with_model(self.client.completions_api(), &self.model)
1050    }
1051}
1052
1053/// The standard response format from OpenAI's Responses API.
1054#[derive(Clone, Debug, Serialize, Deserialize)]
1055pub struct CompletionResponse {
1056    /// The ID of a completion response.
1057    pub id: String,
1058    /// The type of the object.
1059    pub object: ResponseObject,
1060    /// The time at which a given response has been created, in seconds from the UNIX epoch (01/01/1970 00:00:00).
1061    pub created_at: u64,
1062    /// The status of the response.
1063    pub status: ResponseStatus,
1064    /// Response error (optional)
1065    pub error: Option<ResponseError>,
1066    /// Incomplete response details (optional)
1067    pub incomplete_details: Option<IncompleteDetailsReason>,
1068    /// System prompt/preamble
1069    pub instructions: Option<String>,
1070    /// The maximum number of tokens the model should output
1071    pub max_output_tokens: Option<u64>,
1072    /// The model name
1073    pub model: String,
1074    /// Provider-specific top-level reasoning content returned by some
1075    /// OpenAI-compatible Responses implementations.
1076    #[serde(
1077        default,
1078        rename = "reasoning",
1079        deserialize_with = "optional_reasoning_string",
1080        skip_serializing_if = "Option::is_none"
1081    )]
1082    pub provider_reasoning: Option<String>,
1083    /// Token usage
1084    pub usage: Option<ResponsesUsage>,
1085    /// The model output (messages, etc will go here)
1086    #[serde(default)]
1087    pub output: Vec<Output>,
1088    /// Tools
1089    #[serde(default)]
1090    pub tools: Vec<ResponsesToolDefinition>,
1091    /// Additional parameters
1092    #[serde(flatten)]
1093    pub additional_parameters: AdditionalParameters,
1094}
1095
1096/// Additional parameters for the completion request type for OpenAI's Response API: <https://platform.openai.com/docs/api-reference/responses/create>
1097/// Intended to be derived from [`crate::completion::request::CompletionRequest`].
1098#[derive(Clone, Debug, Deserialize, Serialize, Default)]
1099pub struct AdditionalParameters {
1100    /// Whether or not a given model task should run in the background (ie a detached process).
1101    #[serde(skip_serializing_if = "Option::is_none")]
1102    pub background: Option<bool>,
1103    /// The text response format. This is where you would add structured outputs (if you want them).
1104    #[serde(skip_serializing_if = "Option::is_none")]
1105    pub text: Option<TextConfig>,
1106    /// 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!
1107    #[serde(skip_serializing_if = "Option::is_none")]
1108    pub include: Option<Vec<Include>>,
1109    /// `top_p`. Mutually exclusive with the `temperature` argument.
1110    #[serde(skip_serializing_if = "Option::is_none")]
1111    pub top_p: Option<f64>,
1112    /// Whether or not the response should be truncated.
1113    #[serde(skip_serializing_if = "Option::is_none")]
1114    pub truncation: Option<TruncationStrategy>,
1115    /// The username of the user (that you want to use).
1116    #[serde(skip_serializing_if = "Option::is_none")]
1117    pub user: Option<String>,
1118    /// Any additional metadata you'd like to add. This will additionally be returned by the response.
1119    #[serde(skip_serializing_if = "Map::is_empty", default)]
1120    pub metadata: serde_json::Map<String, serde_json::Value>,
1121    /// Whether or not you want tool calls to run in parallel.
1122    #[serde(skip_serializing_if = "Option::is_none")]
1123    pub parallel_tool_calls: Option<bool>,
1124    /// Previous response ID. If you are not sending a full conversation, this can help to track the message flow.
1125    #[serde(skip_serializing_if = "Option::is_none")]
1126    pub previous_response_id: Option<String>,
1127    /// Add thinking/reasoning to your response. The response will be emitted as a list member of the `output` field.
1128    #[serde(skip_serializing_if = "Option::is_none")]
1129    pub reasoning: Option<Reasoning>,
1130    /// The service tier you're using.
1131    #[serde(skip_serializing_if = "Option::is_none")]
1132    pub service_tier: Option<OpenAIServiceTier>,
1133    /// Whether or not to store the response for later retrieval by API.
1134    #[serde(skip_serializing_if = "Option::is_none")]
1135    pub store: Option<bool>,
1136}
1137
1138impl AdditionalParameters {
1139    pub fn to_json(self) -> serde_json::Value {
1140        serde_json::to_value(self).unwrap_or_else(|_| serde_json::Value::Object(Map::new()))
1141    }
1142}
1143
1144/// The truncation strategy.
1145/// 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.
1146/// Otherwise, does nothing (and is disabled by default).
1147#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1148#[serde(rename_all = "snake_case")]
1149pub enum TruncationStrategy {
1150    Auto,
1151    #[default]
1152    Disabled,
1153}
1154
1155/// The model output format configuration.
1156/// You can either have plain text by default, or attach a JSON schema for the purposes of structured outputs.
1157#[derive(Clone, Debug, Serialize, Deserialize)]
1158pub struct TextConfig {
1159    pub format: TextFormat,
1160}
1161
1162impl TextConfig {
1163    pub(crate) fn structured_output<S>(name: S, schema: serde_json::Value) -> Self
1164    where
1165        S: Into<String>,
1166    {
1167        Self {
1168            format: TextFormat::JsonSchema(StructuredOutputsInput {
1169                name: name.into(),
1170                schema,
1171                strict: true,
1172            }),
1173        }
1174    }
1175}
1176
1177/// The text format (contained by [`TextConfig`]).
1178/// You can either have plain text by default, or attach a JSON schema for the purposes of structured outputs.
1179#[derive(Clone, Debug, Serialize, Deserialize, Default)]
1180#[serde(tag = "type")]
1181#[serde(rename_all = "snake_case")]
1182pub enum TextFormat {
1183    JsonSchema(StructuredOutputsInput),
1184    #[default]
1185    Text,
1186}
1187
1188/// The inputs required for adding structured outputs.
1189#[derive(Clone, Debug, Serialize, Deserialize)]
1190pub struct StructuredOutputsInput {
1191    /// The name of your schema.
1192    pub name: String,
1193    /// 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>.
1194    pub schema: serde_json::Value,
1195    /// 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.
1196    #[serde(default)]
1197    pub strict: bool,
1198}
1199
1200/// Add reasoning to a [`CompletionRequest`].
1201#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1202pub struct Reasoning {
1203    /// How much effort you want the model to put into thinking/reasoning.
1204    pub effort: Option<ReasoningEffort>,
1205    /// How much effort you want the model to put into writing the reasoning summary.
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub summary: Option<ReasoningSummaryLevel>,
1208}
1209
1210impl Reasoning {
1211    /// Creates a new Reasoning instantiation (with empty values).
1212    pub fn new() -> Self {
1213        Self {
1214            effort: None,
1215            summary: None,
1216        }
1217    }
1218
1219    /// Adds reasoning effort.
1220    pub fn with_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
1221        self.effort = Some(reasoning_effort);
1222
1223        self
1224    }
1225
1226    /// Adds summary level (how detailed the reasoning summary will be).
1227    pub fn with_summary_level(mut self, reasoning_summary_level: ReasoningSummaryLevel) -> Self {
1228        self.summary = Some(reasoning_summary_level);
1229
1230        self
1231    }
1232}
1233
1234/// The billing service tier that will be used. On auto by default.
1235#[derive(Clone, Debug, Default)]
1236pub enum OpenAIServiceTier {
1237    /// Let OpenAI choose the service tier.
1238    #[default]
1239    Auto,
1240    /// Use the default service tier.
1241    Default,
1242    /// Use the flex service tier.
1243    Flex,
1244    /// Use the priority service tier.
1245    Priority,
1246    /// Use the standard service tier returned by OpenAI-compatible providers.
1247    Standard,
1248    /// Preserve an unknown provider-specific service tier.
1249    Other(String),
1250}
1251
1252impl Serialize for OpenAIServiceTier {
1253    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1254    where
1255        S: Serializer,
1256    {
1257        serializer.serialize_str(match self {
1258            Self::Auto => "auto",
1259            Self::Default => "default",
1260            Self::Flex => "flex",
1261            Self::Priority => "priority",
1262            Self::Standard => "standard",
1263            Self::Other(value) => value,
1264        })
1265    }
1266}
1267
1268impl<'de> Deserialize<'de> for OpenAIServiceTier {
1269    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1270    where
1271        D: Deserializer<'de>,
1272    {
1273        let value = String::deserialize(deserializer)?;
1274        Ok(match value.as_str() {
1275            "auto" => Self::Auto,
1276            "default" => Self::Default,
1277            "flex" => Self::Flex,
1278            "priority" => Self::Priority,
1279            "standard" => Self::Standard,
1280            _ => Self::Other(value),
1281        })
1282    }
1283}
1284
1285/// The amount of reasoning effort that will be used by a given model.
1286#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1287#[serde(rename_all = "snake_case")]
1288pub enum ReasoningEffort {
1289    None,
1290    Minimal,
1291    Low,
1292    #[default]
1293    Medium,
1294    High,
1295    Xhigh,
1296}
1297
1298/// The amount of effort that will go into a reasoning summary by a given model.
1299#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1300#[serde(rename_all = "snake_case")]
1301pub enum ReasoningSummaryLevel {
1302    #[default]
1303    Auto,
1304    Concise,
1305    Detailed,
1306}
1307
1308/// Results to additionally include in the OpenAI Responses API.
1309/// Note that most of these are currently unsupported, but have been added for completeness.
1310#[derive(Clone, Debug, Deserialize, Serialize)]
1311pub enum Include {
1312    #[serde(rename = "file_search_call.results")]
1313    FileSearchCallResults,
1314    #[serde(rename = "message.input_image.image_url")]
1315    MessageInputImageImageUrl,
1316    #[serde(rename = "computer_call.output.image_url")]
1317    ComputerCallOutputOutputImageUrl,
1318    #[serde(rename = "reasoning.encrypted_content")]
1319    ReasoningEncryptedContent,
1320    #[serde(rename = "code_interpreter_call.outputs")]
1321    CodeInterpreterCallOutputs,
1322}
1323
1324/// A currently non-exhaustive list of output types.
1325#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1326#[serde(tag = "type")]
1327#[serde(rename_all = "snake_case")]
1328pub enum Output {
1329    Message(OutputMessage),
1330    #[serde(alias = "function_call")]
1331    FunctionCall(OutputFunctionCall),
1332    Reasoning {
1333        id: String,
1334        summary: Vec<ReasoningSummary>,
1335        #[serde(default)]
1336        encrypted_content: Option<String>,
1337        #[serde(default)]
1338        status: Option<ToolStatus>,
1339    },
1340    /// Catch-all variant for unknown output types (e.g., `web_search_call`,
1341    /// `file_search_call`, `computer_use_call`). This prevents unknown types
1342    /// from breaking deserialization of the entire `CompletionResponse`,
1343    /// which previously caused streaming token usage to be silently dropped.
1344    #[serde(other)]
1345    Unknown,
1346}
1347
1348impl From<Output> for Vec<completion::AssistantContent> {
1349    fn from(value: Output) -> Self {
1350        let res: Vec<completion::AssistantContent> = match value {
1351            Output::Message(OutputMessage { content, .. }) => content
1352                .into_iter()
1353                .map(completion::AssistantContent::from)
1354                .collect(),
1355            Output::FunctionCall(OutputFunctionCall {
1356                id,
1357                arguments,
1358                call_id,
1359                name,
1360                ..
1361            }) => vec![completion::AssistantContent::tool_call_with_call_id(
1362                id, call_id, name, arguments,
1363            )],
1364            Output::Reasoning {
1365                id,
1366                summary,
1367                encrypted_content,
1368                ..
1369            } => {
1370                let mut content = summary
1371                    .into_iter()
1372                    .map(|summary| match summary {
1373                        ReasoningSummary::SummaryText { text } => {
1374                            message::ReasoningContent::Summary(text)
1375                        }
1376                    })
1377                    .collect::<Vec<_>>();
1378                if let Some(encrypted_content) = encrypted_content {
1379                    content.push(message::ReasoningContent::Encrypted(encrypted_content));
1380                }
1381                vec![completion::AssistantContent::Reasoning(
1382                    message::Reasoning {
1383                        id: Some(id),
1384                        content,
1385                    },
1386                )]
1387            }
1388            Output::Unknown => Vec::new(),
1389        };
1390
1391        res
1392    }
1393}
1394
1395#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1396pub struct OutputReasoning {
1397    id: String,
1398    summary: Vec<ReasoningSummary>,
1399    status: ToolStatus,
1400}
1401
1402/// 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.
1403#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1404pub struct OutputFunctionCall {
1405    pub id: String,
1406    #[serde(with = "json_utils::stringified_json")]
1407    pub arguments: serde_json::Value,
1408    pub call_id: String,
1409    pub name: String,
1410    pub status: ToolStatus,
1411}
1412
1413/// The status of a given tool.
1414#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1415#[serde(rename_all = "snake_case")]
1416pub enum ToolStatus {
1417    InProgress,
1418    Completed,
1419    Incomplete,
1420}
1421
1422/// An output message from OpenAI's Responses API.
1423#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1424pub struct OutputMessage {
1425    /// The message ID. Must be included when sending the message back to OpenAI
1426    pub id: String,
1427    /// The role (currently only Assistant is available as this struct is only created when receiving an LLM message as a response)
1428    pub role: OutputRole,
1429    /// The status of the response
1430    pub status: ResponseStatus,
1431    /// The actual message content
1432    pub content: Vec<AssistantContent>,
1433}
1434
1435/// The role of an output message.
1436#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1437#[serde(rename_all = "snake_case")]
1438pub enum OutputRole {
1439    Assistant,
1440}
1441
1442impl<Ext, H> completion::CompletionModel for GenericResponsesCompletionModel<Ext, H>
1443where
1444    crate::client::Client<Ext, H>:
1445        HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static,
1446    Ext: crate::client::Provider
1447        + crate::client::DebugExt
1448        + Clone
1449        + WasmCompatSend
1450        + WasmCompatSync
1451        + 'static,
1452    H: Clone + Default + std::fmt::Debug + WasmCompatSend + WasmCompatSync + 'static,
1453{
1454    type Response = CompletionResponse;
1455    type StreamingResponse = StreamingCompletionResponse;
1456
1457    type Client = crate::client::Client<Ext, H>;
1458
1459    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1460        Self::new(client.clone(), model)
1461    }
1462
1463    async fn completion(
1464        &self,
1465        completion_request: crate::completion::CompletionRequest,
1466    ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1467        let span = if tracing::Span::current().is_disabled() {
1468            info_span!(
1469                target: "rig::completions",
1470                "chat",
1471                gen_ai.operation.name = "chat",
1472                gen_ai.provider.name = tracing::field::Empty,
1473                gen_ai.request.model = tracing::field::Empty,
1474                gen_ai.response.id = tracing::field::Empty,
1475                gen_ai.response.model = tracing::field::Empty,
1476                gen_ai.usage.output_tokens = tracing::field::Empty,
1477                gen_ai.usage.input_tokens = tracing::field::Empty,
1478                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1479                gen_ai.input.messages = tracing::field::Empty,
1480                gen_ai.output.messages = tracing::field::Empty,
1481            )
1482        } else {
1483            tracing::Span::current()
1484        };
1485
1486        span.record("gen_ai.provider.name", "openai");
1487        span.record("gen_ai.request.model", &self.model);
1488        let request = self.create_completion_request(completion_request)?;
1489        let body = serde_json::to_vec(&request)?;
1490
1491        if enabled!(Level::TRACE) {
1492            tracing::trace!(
1493                target: "rig::completions",
1494                "OpenAI Responses completion request: {request}",
1495                request = serde_json::to_string_pretty(&request)?
1496            );
1497        }
1498
1499        let req = self
1500            .client
1501            .post("/responses")?
1502            .body(body)
1503            .map_err(|e| CompletionError::HttpError(e.into()))?;
1504
1505        async move {
1506            let response = self.client.send(req).await?;
1507
1508            if response.status().is_success() {
1509                let t = http_client::text(response).await?;
1510                let response = serde_json::from_str::<Self::Response>(&t)?;
1511                let span = tracing::Span::current();
1512                span.record("gen_ai.response.id", &response.id);
1513                span.record("gen_ai.response.model", &response.model);
1514                if let Some(ref usage) = response.usage {
1515                    span.record("gen_ai.usage.output_tokens", usage.output_tokens);
1516                    span.record("gen_ai.usage.input_tokens", usage.input_tokens);
1517                    let cached_tokens = usage
1518                        .input_tokens_details
1519                        .as_ref()
1520                        .map(|d| d.cached_tokens)
1521                        .unwrap_or(0);
1522                    span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
1523                }
1524                if enabled!(Level::TRACE) {
1525                    tracing::trace!(
1526                        target: "rig::completions",
1527                        "OpenAI Responses completion response: {response}",
1528                        response = serde_json::to_string_pretty(&response)?
1529                    );
1530                }
1531                response.try_into()
1532            } else {
1533                let text = http_client::text(response).await?;
1534                Err(CompletionError::ProviderError(text))
1535            }
1536        }
1537        .instrument(span)
1538        .await
1539    }
1540
1541    async fn stream(
1542        &self,
1543        request: crate::completion::CompletionRequest,
1544    ) -> Result<
1545        crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
1546        CompletionError,
1547    > {
1548        GenericResponsesCompletionModel::stream(self, request).await
1549    }
1550}
1551
1552impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
1553    type Error = CompletionError;
1554
1555    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
1556        // Extract the msg_ ID from the first Output::Message item
1557        let message_id = response.output.iter().find_map(|item| match item {
1558            Output::Message(msg) => Some(msg.id.clone()),
1559            _ => None,
1560        });
1561
1562        let output_content: Vec<completion::AssistantContent> = response
1563            .output
1564            .iter()
1565            .cloned()
1566            .flat_map(<Vec<completion::AssistantContent>>::from)
1567            .collect();
1568        let has_structured_reasoning = response
1569            .output
1570            .iter()
1571            .any(|item| matches!(item, Output::Reasoning { .. }));
1572        let content = response
1573            .provider_reasoning
1574            .as_ref()
1575            .filter(|reasoning| !has_structured_reasoning && !reasoning.is_empty())
1576            .map(|reasoning| {
1577                let mut content = Vec::with_capacity(output_content.len() + 1);
1578                content.push(completion::AssistantContent::Reasoning(
1579                    message::Reasoning::new(reasoning),
1580                ));
1581                content.extend(output_content.clone());
1582                content
1583            })
1584            .unwrap_or(output_content);
1585
1586        let choice = OneOrMany::many(content).map_err(|_| {
1587            CompletionError::ResponseError(
1588                "Response contained no message or tool call (empty)".to_owned(),
1589            )
1590        })?;
1591
1592        let usage = response
1593            .usage
1594            .as_ref()
1595            .and_then(GetTokenUsage::token_usage)
1596            .unwrap_or_default();
1597
1598        Ok(completion::CompletionResponse {
1599            choice,
1600            usage,
1601            raw_response: response,
1602            message_id,
1603        })
1604    }
1605}
1606
1607/// An OpenAI Responses API message.
1608#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1609#[serde(tag = "role", rename_all = "lowercase")]
1610pub enum Message {
1611    #[serde(alias = "developer")]
1612    System {
1613        #[serde(deserialize_with = "string_or_one_or_many")]
1614        content: OneOrMany<SystemContent>,
1615        #[serde(skip_serializing_if = "Option::is_none")]
1616        name: Option<String>,
1617    },
1618    User {
1619        #[serde(deserialize_with = "string_or_one_or_many")]
1620        content: OneOrMany<UserContent>,
1621        #[serde(skip_serializing_if = "Option::is_none")]
1622        name: Option<String>,
1623    },
1624    Assistant {
1625        content: OneOrMany<AssistantContentType>,
1626        #[serde(skip_serializing_if = "String::is_empty")]
1627        id: String,
1628        #[serde(skip_serializing_if = "Option::is_none")]
1629        name: Option<String>,
1630        status: ToolStatus,
1631    },
1632    #[serde(rename = "tool")]
1633    ToolResult {
1634        tool_call_id: String,
1635        output: String,
1636    },
1637}
1638
1639/// The type of a tool result content item.
1640#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
1641#[serde(rename_all = "lowercase")]
1642pub enum ToolResultContentType {
1643    #[default]
1644    Text,
1645}
1646
1647impl Message {
1648    pub fn system(content: &str) -> Self {
1649        Message::System {
1650            content: OneOrMany::one(content.to_owned().into()),
1651            name: None,
1652        }
1653    }
1654}
1655
1656/// Text assistant content.
1657/// Note that the text type in comparison to the Completions API is actually `output_text` rather than `text`.
1658#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1659#[serde(tag = "type", rename_all = "snake_case")]
1660pub enum AssistantContent {
1661    InputText { text: String },
1662    OutputText(Text),
1663    Refusal { refusal: String },
1664}
1665
1666impl From<AssistantContent> for completion::AssistantContent {
1667    fn from(value: AssistantContent) -> Self {
1668        match value {
1669            AssistantContent::InputText { text } => {
1670                completion::AssistantContent::Text(Text::new(text))
1671            }
1672            AssistantContent::Refusal { refusal } => {
1673                completion::AssistantContent::Text(Text::new(refusal))
1674            }
1675            AssistantContent::OutputText(Text { text, .. }) => {
1676                completion::AssistantContent::Text(Text::new(text))
1677            }
1678        }
1679    }
1680}
1681
1682/// The type of assistant content.
1683#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1684#[serde(untagged)]
1685pub enum AssistantContentType {
1686    Text(AssistantContent),
1687    ToolCall(OutputFunctionCall),
1688    Reasoning(OpenAIReasoning),
1689}
1690
1691/// System content for the OpenAI Responses API.
1692/// Uses `input_text` type to match the Responses API format.
1693#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1694#[serde(tag = "type", rename_all = "snake_case")]
1695pub enum SystemContent {
1696    InputText { text: String },
1697}
1698
1699impl From<String> for SystemContent {
1700    fn from(s: String) -> Self {
1701        SystemContent::InputText { text: s }
1702    }
1703}
1704
1705impl std::str::FromStr for SystemContent {
1706    type Err = std::convert::Infallible;
1707
1708    fn from_str(s: &str) -> Result<Self, Self::Err> {
1709        Ok(SystemContent::InputText {
1710            text: s.to_string(),
1711        })
1712    }
1713}
1714
1715/// Different types of user content.
1716#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1717#[serde(tag = "type", rename_all = "snake_case")]
1718pub enum UserContent {
1719    InputText {
1720        text: String,
1721    },
1722    InputImage {
1723        image_url: String,
1724        #[serde(default)]
1725        detail: ImageDetail,
1726    },
1727    InputFile {
1728        #[serde(skip_serializing_if = "Option::is_none")]
1729        file_id: Option<String>,
1730        #[serde(skip_serializing_if = "Option::is_none")]
1731        file_url: Option<String>,
1732        #[serde(skip_serializing_if = "Option::is_none")]
1733        file_data: Option<String>,
1734        #[serde(skip_serializing_if = "Option::is_none")]
1735        filename: Option<String>,
1736    },
1737    Audio {
1738        input_audio: InputAudio,
1739    },
1740    #[serde(rename = "tool")]
1741    ToolResult {
1742        tool_call_id: String,
1743        output: String,
1744    },
1745}
1746
1747impl TryFrom<message::Message> for Vec<Message> {
1748    type Error = message::MessageError;
1749
1750    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1751        match message {
1752            message::Message::System { content } => Ok(vec![Message::System {
1753                content: OneOrMany::one(content.into()),
1754                name: None,
1755            }]),
1756            message::Message::User { content } => {
1757                let (tool_results, other_content): (Vec<_>, Vec<_>) = content
1758                    .into_iter()
1759                    .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1760
1761                // If there are messages with both tool results and user content, openai will only
1762                //  handle tool results. It's unlikely that there will be both.
1763                if !tool_results.is_empty() {
1764                    tool_results
1765                        .into_iter()
1766                        .map(|content| match content {
1767                            message::UserContent::ToolResult(message::ToolResult {
1768                                call_id,
1769                                content,
1770                                ..
1771                            }) => Ok::<_, message::MessageError>(Message::ToolResult {
1772                                tool_call_id: call_id.ok_or_else(|| {
1773                                    MessageError::ConversionError(
1774                                        "Tool result `call_id` is required for OpenAI Responses API"
1775                                            .into(),
1776                                    )
1777                                })?,
1778                                output: {
1779                                    let res = content.first();
1780                                    match res {
1781                                        completion::message::ToolResultContent::Text(Text {
1782                                            text,
1783                                            ..
1784                                        }) => text,
1785                                        _ => return  Err(MessageError::ConversionError("This API only currently supports text tool results".into()))
1786                                    }
1787                                },
1788                            }),
1789                            _ => Err(MessageError::ConversionError(
1790                                "expected tool result content while converting Responses API input"
1791                                    .into(),
1792                            )),
1793                        })
1794                        .collect::<Result<Vec<_>, _>>()
1795                } else {
1796                    let other_content = other_content
1797                        .into_iter()
1798                        .map(|content| match content {
1799                            message::UserContent::Text(message::Text { text, .. }) => {
1800                                Ok(UserContent::InputText { text })
1801                            }
1802                            message::UserContent::Image(message::Image {
1803                                data,
1804                                detail,
1805                                media_type,
1806                                ..
1807                            }) => {
1808                                let url = match data {
1809                                    DocumentSourceKind::Base64(data) => {
1810                                        let media_type = if let Some(media_type) = media_type {
1811                                            media_type.to_mime_type().to_string()
1812                                        } else {
1813                                            String::new()
1814                                        };
1815                                        format!("data:{media_type};base64,{data}")
1816                                    }
1817                                    DocumentSourceKind::Url(url) => url,
1818                                    DocumentSourceKind::Raw(_) => {
1819                                        return Err(MessageError::ConversionError(
1820                                            "Raw files not supported, encode as base64 first"
1821                                                .into(),
1822                                        ));
1823                                    }
1824                                    doc => {
1825                                        return Err(MessageError::ConversionError(format!(
1826                                            "Unsupported document type: {doc}"
1827                                        )));
1828                                    }
1829                                };
1830
1831                                Ok(UserContent::InputImage {
1832                                    image_url: url,
1833                                    detail: detail.unwrap_or_default(),
1834                                })
1835                            }
1836                            message::UserContent::Document(message::Document {
1837                                data: DocumentSourceKind::FileId(file_id),
1838                                ..
1839                            }) => Ok(UserContent::InputFile {
1840                                file_id: Some(file_id),
1841                                file_url: None,
1842                                file_data: None,
1843                                filename: None,
1844                            }),
1845                            message::UserContent::Document(message::Document {
1846                                media_type: Some(DocumentMediaType::PDF),
1847                                data,
1848                                ..
1849                            }) => {
1850                                let (file_data, file_url, filename) = match data {
1851                                    DocumentSourceKind::Base64(data) => (
1852                                        Some(format!("data:application/pdf;base64,{data}")),
1853                                        None,
1854                                        Some("document.pdf".to_string()),
1855                                    ),
1856                                    DocumentSourceKind::Url(url) => (None, Some(url), None),
1857                                    DocumentSourceKind::Raw(_) => {
1858                                        return Err(MessageError::ConversionError(
1859                                            "Raw files not supported, encode as base64 first"
1860                                                .into(),
1861                                        ));
1862                                    }
1863                                    doc => {
1864                                        return Err(MessageError::ConversionError(format!(
1865                                            "Unsupported document type: {doc}"
1866                                        )));
1867                                    }
1868                                };
1869
1870                                Ok(UserContent::InputFile {
1871                                    file_id: None,
1872                                    file_url,
1873                                    file_data,
1874                                    filename,
1875                                })
1876                            }
1877                            message::UserContent::Document(message::Document {
1878                                data: DocumentSourceKind::Base64(text),
1879                                ..
1880                            }) => Ok(UserContent::InputText { text }),
1881                            message::UserContent::Audio(message::Audio {
1882                                data: DocumentSourceKind::Base64(data),
1883                                media_type,
1884                                ..
1885                            }) => Ok(UserContent::Audio {
1886                                input_audio: InputAudio {
1887                                    data,
1888                                    format: match media_type {
1889                                        Some(media_type) => media_type,
1890                                        None => AudioMediaType::MP3,
1891                                    },
1892                                },
1893                            }),
1894                            message::UserContent::Audio(_) => Err(MessageError::ConversionError(
1895                                "Audio must be base64 encoded data".into(),
1896                            )),
1897                            _ => Err(MessageError::ConversionError(
1898                                "Unsupported user content for OpenAI Responses API".into(),
1899                            )),
1900                        })
1901                        .collect::<Result<Vec<_>, _>>()?;
1902
1903                    let other_content = OneOrMany::many(other_content).map_err(|_| {
1904                        MessageError::ConversionError(
1905                            "User message did not contain OpenAI Responses-compatible content"
1906                                .to_string(),
1907                        )
1908                    })?;
1909
1910                    Ok(vec![Message::User {
1911                        content: other_content,
1912                        name: None,
1913                    }])
1914                }
1915            }
1916            message::Message::Assistant { content, id } => {
1917                let cannot_replay_without_provider_id = id.is_none();
1918                let assistant_message_id = id.unwrap_or_default();
1919                let mut messages = Vec::new();
1920                let content = content.into_iter().collect::<Vec<_>>();
1921                let has_unreplayable_reasoning = content.iter().any(|assistant_content| {
1922                    matches!(
1923                        assistant_content,
1924                        crate::message::AssistantContent::Reasoning(reasoning)
1925                            if reasoning.id.is_none()
1926                    )
1927                });
1928                let cannot_replay_as_provider_output =
1929                    cannot_replay_without_provider_id || has_unreplayable_reasoning;
1930
1931                for assistant_content in content {
1932                    match assistant_content {
1933                        crate::message::AssistantContent::Text(Text { text, .. }) => {
1934                            if text.is_empty() {
1935                                continue;
1936                            }
1937                            let text = if cannot_replay_as_provider_output {
1938                                AssistantContent::InputText { text }
1939                            } else {
1940                                AssistantContent::OutputText(Text::new(text))
1941                            };
1942                            messages.push(Message::Assistant {
1943                                id: assistant_message_id.clone(),
1944                                status: ToolStatus::Completed,
1945                                content: OneOrMany::one(AssistantContentType::Text(text)),
1946                                name: None,
1947                            });
1948                        }
1949                        crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
1950                            id,
1951                            call_id,
1952                            function,
1953                            ..
1954                        }) => {
1955                            messages.push(Message::Assistant {
1956                                content: OneOrMany::one(AssistantContentType::ToolCall(
1957                                    OutputFunctionCall {
1958                                        call_id: call_id.ok_or_else(|| {
1959                                            MessageError::ConversionError(
1960                                                "Tool call `call_id` is required for OpenAI Responses API"
1961                                                    .into(),
1962                                            )
1963                                        })?,
1964                                        arguments: function.arguments,
1965                                        id,
1966                                        name: function.name,
1967                                        status: ToolStatus::Completed,
1968                                    },
1969                                )),
1970                                id: assistant_message_id.clone(),
1971                                name: None,
1972                                status: ToolStatus::Completed,
1973                            });
1974                        }
1975                        crate::message::AssistantContent::Reasoning(reasoning) => {
1976                            if let Some(openai_reasoning) = openai_reasoning_from_core(&reasoning)?
1977                            {
1978                                messages.push(Message::Assistant {
1979                                    content: OneOrMany::one(AssistantContentType::Reasoning(
1980                                        openai_reasoning,
1981                                    )),
1982                                    id: assistant_message_id.clone(),
1983                                    name: None,
1984                                    status: ToolStatus::Completed,
1985                                });
1986                            }
1987                        }
1988                        crate::message::AssistantContent::Image(_) => {
1989                            return Err(MessageError::ConversionError(
1990                                "Assistant image content is not supported in OpenAI Responses API"
1991                                    .into(),
1992                            ));
1993                        }
1994                    }
1995                }
1996
1997                Ok(messages)
1998            }
1999        }
2000    }
2001}
2002
2003impl FromStr for UserContent {
2004    type Err = Infallible;
2005
2006    fn from_str(s: &str) -> Result<Self, Self::Err> {
2007        Ok(UserContent::InputText {
2008            text: s.to_string(),
2009        })
2010    }
2011}
2012
2013#[cfg(test)]
2014mod tests {
2015    use super::*;
2016    use crate::message;
2017    use serde_json::json;
2018
2019    fn response_with_service_tier(service_tier: &str) -> Value {
2020        json!({
2021            "id": "resp_123",
2022            "object": "response",
2023            "created_at": 0,
2024            "status": "completed",
2025            "model": "gpt-5.4",
2026            "output": [],
2027            "service_tier": service_tier,
2028        })
2029    }
2030
2031    #[test]
2032    fn completion_response_deserializes_standard_service_tier() {
2033        let response: CompletionResponse =
2034            serde_json::from_value(response_with_service_tier("standard"))
2035                .expect("response should deserialize");
2036
2037        assert!(matches!(
2038            response.additional_parameters.service_tier,
2039            Some(OpenAIServiceTier::Standard)
2040        ));
2041    }
2042
2043    #[test]
2044    fn completion_response_deserializes_priority_service_tier() {
2045        let response: CompletionResponse =
2046            serde_json::from_value(response_with_service_tier("priority"))
2047                .expect("response should deserialize");
2048
2049        assert!(matches!(
2050            response.additional_parameters.service_tier,
2051            Some(OpenAIServiceTier::Priority)
2052        ));
2053    }
2054
2055    #[test]
2056    fn completion_response_preserves_unknown_service_tier() {
2057        let response: CompletionResponse =
2058            serde_json::from_value(response_with_service_tier("provider_experimental"))
2059                .expect("response should deserialize");
2060
2061        let Some(OpenAIServiceTier::Other(service_tier)) =
2062            response.additional_parameters.service_tier
2063        else {
2064            panic!("expected provider-specific service tier");
2065        };
2066
2067        assert_eq!(service_tier, "provider_experimental");
2068    }
2069
2070    #[test]
2071    fn service_tier_serializes_expected_strings() {
2072        let cases = [
2073            (OpenAIServiceTier::Auto, "auto"),
2074            (OpenAIServiceTier::Default, "default"),
2075            (OpenAIServiceTier::Flex, "flex"),
2076            (OpenAIServiceTier::Priority, "priority"),
2077            (OpenAIServiceTier::Standard, "standard"),
2078        ];
2079
2080        for (service_tier, expected) in cases {
2081            assert_eq!(
2082                serde_json::to_value(service_tier).expect("service tier should serialize"),
2083                json!(expected)
2084            );
2085        }
2086
2087        assert_eq!(
2088            serde_json::to_value(OpenAIServiceTier::Other(
2089                "provider_experimental".to_string()
2090            ))
2091            .expect("provider-specific service tier should serialize"),
2092            json!("provider_experimental")
2093        );
2094    }
2095
2096    #[test]
2097    fn responses_usage_token_usage_preserves_reasoning_tokens() {
2098        let usage = ResponsesUsage {
2099            input_tokens: 100,
2100            input_tokens_details: Some(InputTokensDetails { cached_tokens: 25 }),
2101            output_tokens: 50,
2102            output_tokens_details: Some(OutputTokensDetails {
2103                reasoning_tokens: 15,
2104            }),
2105            total_tokens: 150,
2106        };
2107
2108        let token_usage = usage.token_usage().expect("usage should be present");
2109
2110        assert_eq!(token_usage.input_tokens, 100);
2111        assert_eq!(token_usage.cached_input_tokens, 25);
2112        assert_eq!(token_usage.output_tokens, 50);
2113        assert_eq!(token_usage.reasoning_tokens, 15);
2114        assert_eq!(token_usage.total_tokens, 150);
2115    }
2116
2117    #[test]
2118    fn responses_usage_deserializes_without_output_token_details() {
2119        let usage: ResponsesUsage = serde_json::from_value(json!({
2120            "input_tokens": 100,
2121            "input_tokens_details": {
2122                "cached_tokens": 25
2123            },
2124            "output_tokens": 50,
2125            "total_tokens": 150
2126        }))
2127        .expect("usage should deserialize when output token details are omitted");
2128
2129        assert!(usage.output_tokens_details.is_none());
2130
2131        let token_usage = usage.token_usage().expect("usage should be present");
2132
2133        assert_eq!(token_usage.input_tokens, 100);
2134        assert_eq!(token_usage.cached_input_tokens, 25);
2135        assert_eq!(token_usage.output_tokens, 50);
2136        assert_eq!(token_usage.reasoning_tokens, 0);
2137        assert_eq!(token_usage.total_tokens, 150);
2138    }
2139
2140    #[test]
2141    fn completion_response_accepts_top_level_reasoning_string() {
2142        let response: CompletionResponse = serde_json::from_value(json!({
2143            "id": "resp_123",
2144            "object": "response",
2145            "created_at": 0,
2146            "status": "completed",
2147            "model": "Qwen/Qwen3-4B",
2148            "reasoning": "thinking through the answer",
2149            "usage": {
2150                "input_tokens": 1,
2151                "output_tokens": 2,
2152                "total_tokens": 3
2153            },
2154            "output": [{
2155                "type": "message",
2156                "id": "msg_123",
2157                "status": "completed",
2158                "role": "assistant",
2159                "content": [{
2160                    "type": "output_text",
2161                    "annotations": [],
2162                    "text": "done"
2163                }]
2164            }],
2165            "tools": []
2166        }))
2167        .expect("mistral.rs-style reasoning string should deserialize");
2168
2169        assert_eq!(
2170            response.provider_reasoning.as_deref(),
2171            Some("thinking through the answer")
2172        );
2173
2174        let completion: completion::CompletionResponse<CompletionResponse> =
2175            response.try_into().expect("response should convert");
2176        let items = completion.choice.iter().collect::<Vec<_>>();
2177        assert!(matches!(
2178            items[0],
2179            completion::AssistantContent::Reasoning(_)
2180        ));
2181        assert!(matches!(items[1], completion::AssistantContent::Text(_)));
2182    }
2183
2184    #[test]
2185    fn completion_response_accepts_reasoning_only_response() {
2186        let response: CompletionResponse = serde_json::from_value(json!({
2187            "id": "resp_123",
2188            "object": "response",
2189            "created_at": 0,
2190            "status": "completed",
2191            "model": "Qwen/Qwen3-4B",
2192            "reasoning": "thinking only",
2193            "usage": {
2194                "input_tokens": 1,
2195                "output_tokens": 2,
2196                "total_tokens": 3
2197            },
2198            "output": [],
2199            "tools": []
2200        }))
2201        .expect("reasoning-only response should deserialize");
2202
2203        let completion: completion::CompletionResponse<CompletionResponse> = response
2204            .try_into()
2205            .expect("reasoning-only response should convert");
2206        let items = completion.choice.iter().collect::<Vec<_>>();
2207
2208        assert_eq!(items.len(), 1);
2209        assert!(matches!(
2210            items[0],
2211            completion::AssistantContent::Reasoning(_)
2212        ));
2213    }
2214
2215    #[test]
2216    fn completion_response_rejects_empty_response_without_reasoning() {
2217        let response: CompletionResponse = serde_json::from_value(json!({
2218            "id": "resp_123",
2219            "object": "response",
2220            "created_at": 0,
2221            "status": "completed",
2222            "model": "Qwen/Qwen3-4B",
2223            "output": [],
2224            "tools": []
2225        }))
2226        .expect("empty response shape should deserialize");
2227
2228        let err = completion::CompletionResponse::<CompletionResponse>::try_from(response)
2229            .expect_err("empty response without reasoning should be rejected");
2230
2231        assert!(
2232            err.to_string()
2233                .contains("Response contained no message or tool call")
2234        );
2235    }
2236
2237    #[test]
2238    fn completion_response_ignores_top_level_reasoning_object_as_text() {
2239        let response: CompletionResponse = serde_json::from_value(json!({
2240            "id": "resp_123",
2241            "object": "response",
2242            "created_at": 0,
2243            "status": "completed",
2244            "model": "Qwen/Qwen3-4B",
2245            "reasoning": {
2246                "effort": "high"
2247            },
2248            "output": [{
2249                "type": "message",
2250                "id": "msg_123",
2251                "status": "completed",
2252                "role": "assistant",
2253                "content": [{
2254                    "type": "output_text",
2255                    "annotations": [],
2256                    "text": "done"
2257                }]
2258            }],
2259            "tools": []
2260        }))
2261        .expect("object-shaped reasoning should be tolerated");
2262
2263        assert!(response.provider_reasoning.is_none());
2264
2265        let completion: completion::CompletionResponse<CompletionResponse> =
2266            response.try_into().expect("response should convert");
2267        let items = completion.choice.iter().collect::<Vec<_>>();
2268        assert_eq!(items.len(), 1);
2269        assert!(matches!(items[0], completion::AssistantContent::Text(_)));
2270    }
2271
2272    #[test]
2273    fn completion_response_does_not_duplicate_structured_reasoning() {
2274        let response: CompletionResponse = serde_json::from_value(json!({
2275            "id": "resp_123",
2276            "object": "response",
2277            "created_at": 0,
2278            "status": "completed",
2279            "model": "gpt-5.4",
2280            "reasoning": "provider top-level text",
2281            "output": [{
2282                "type": "reasoning",
2283                "id": "rs_123",
2284                "summary": [{
2285                    "type": "summary_text",
2286                    "text": "structured summary"
2287                }]
2288            }, {
2289                "type": "message",
2290                "id": "msg_123",
2291                "status": "completed",
2292                "role": "assistant",
2293                "content": [{
2294                    "type": "output_text",
2295                    "annotations": [],
2296                    "text": "done"
2297                }]
2298            }],
2299            "tools": []
2300        }))
2301        .expect("response should deserialize");
2302
2303        let completion: completion::CompletionResponse<CompletionResponse> =
2304            response.try_into().expect("response should convert");
2305        let reasoning_count = completion
2306            .choice
2307            .iter()
2308            .filter(|item| matches!(item, completion::AssistantContent::Reasoning(_)))
2309            .count();
2310
2311        assert_eq!(reasoning_count, 1);
2312    }
2313
2314    #[test]
2315    fn idless_reasoning_is_skipped_when_converting_responses_history() {
2316        let assistant = message::Message::Assistant {
2317            id: Some("msg_123".to_string()),
2318            content: OneOrMany::one(message::AssistantContent::Reasoning(
2319                message::Reasoning::new("provider reasoning"),
2320            )),
2321        };
2322
2323        let converted = Vec::<Message>::try_from(assistant)
2324            .expect("idless reasoning should degrade gracefully");
2325
2326        assert!(converted.is_empty());
2327    }
2328
2329    #[test]
2330    fn idless_reasoning_only_is_skipped_without_empty_input_item() {
2331        let assistant = completion::Message::Assistant {
2332            id: None,
2333            content: OneOrMany::one(message::AssistantContent::Reasoning(
2334                message::Reasoning::new("provider reasoning"),
2335            )),
2336        };
2337
2338        let converted = Vec::<InputItem>::try_from(assistant)
2339            .expect("idless reasoning should degrade gracefully");
2340
2341        assert!(converted.is_empty());
2342    }
2343
2344    #[test]
2345    fn idless_reasoning_plus_text_preserves_text_for_responses_history() {
2346        let assistant = message::Message::Assistant {
2347            id: Some("msg_123".to_string()),
2348            content: OneOrMany::many(vec![
2349                message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
2350                message::AssistantContent::Text(Text::new("final answer")),
2351            ])
2352            .expect("assistant content should be non-empty"),
2353        };
2354
2355        let converted =
2356            Vec::<Message>::try_from(assistant).expect("assistant history should convert");
2357
2358        assert_eq!(converted.len(), 1);
2359        let Message::Assistant { content, .. } = &converted[0] else {
2360            panic!("expected assistant message");
2361        };
2362        assert!(matches!(
2363            content.first_ref(),
2364            AssistantContentType::Text(AssistantContent::InputText { text }) if text == "final answer"
2365        ));
2366    }
2367
2368    #[test]
2369    fn completion_history_idless_reasoning_plus_text_preserves_text_input_item() {
2370        let assistant = completion::Message::Assistant {
2371            id: Some("msg_123".to_string()),
2372            content: OneOrMany::many(vec![
2373                message::AssistantContent::Reasoning(message::Reasoning::new("provider reasoning")),
2374                message::AssistantContent::Text(Text::new("final answer")),
2375            ])
2376            .expect("assistant content should be non-empty"),
2377        };
2378
2379        let converted =
2380            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2381
2382        assert_eq!(converted.len(), 1);
2383        assert!(matches!(converted[0].role, Some(Role::Assistant)));
2384        let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
2385            panic!("expected assistant message input item");
2386        };
2387        assert!(matches!(
2388            content.first_ref(),
2389            AssistantContentType::Text(AssistantContent::InputText { text }) if text == "final answer"
2390        ));
2391    }
2392
2393    #[test]
2394    fn assistant_text_without_idless_reasoning_replays_as_output_text() {
2395        let assistant = completion::Message::Assistant {
2396            id: Some("msg_123".to_string()),
2397            content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
2398        };
2399
2400        let converted =
2401            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2402
2403        assert_eq!(converted.len(), 1);
2404        let InputContent::Message(Message::Assistant { content, .. }) = &converted[0].input else {
2405            panic!("expected assistant message input item");
2406        };
2407        assert!(matches!(
2408            content.first_ref(),
2409            AssistantContentType::Text(AssistantContent::OutputText(Text { text, .. })) if text == "final answer"
2410        ));
2411    }
2412
2413    #[test]
2414    fn idless_completion_assistant_text_replays_as_input_text() {
2415        let assistant = completion::Message::Assistant {
2416            id: None,
2417            content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
2418        };
2419
2420        let converted =
2421            Vec::<InputItem>::try_from(assistant).expect("assistant history should convert");
2422
2423        assert_eq!(converted.len(), 1);
2424        assert!(matches!(converted[0].role, Some(Role::Assistant)));
2425        let InputContent::Message(Message::Assistant { content, id, .. }) = &converted[0].input
2426        else {
2427            panic!("expected assistant message input item");
2428        };
2429        assert!(id.is_empty());
2430        assert!(matches!(
2431            content.first_ref(),
2432            AssistantContentType::Text(AssistantContent::InputText { text }) if text == "final answer"
2433        ));
2434
2435        let serialized =
2436            serde_json::to_value(&converted[0]).expect("input item should serialize to JSON");
2437        assert_eq!(serialized["content"][0]["type"], json!("input_text"));
2438        assert!(serialized.get("id").is_none());
2439    }
2440
2441    #[test]
2442    fn idless_message_assistant_text_replays_as_input_text() {
2443        let assistant = message::Message::Assistant {
2444            id: None,
2445            content: OneOrMany::one(message::AssistantContent::Text(Text::new("final answer"))),
2446        };
2447
2448        let converted =
2449            Vec::<Message>::try_from(assistant).expect("assistant history should convert");
2450
2451        assert_eq!(converted.len(), 1);
2452        let Message::Assistant { content, id, .. } = &converted[0] else {
2453            panic!("expected assistant message");
2454        };
2455        assert!(id.is_empty());
2456        assert!(matches!(
2457            content.first_ref(),
2458            AssistantContentType::Text(AssistantContent::InputText { text }) if text == "final answer"
2459        ));
2460
2461        let serialized = serde_json::to_value(&converted[0])
2462            .expect("assistant message should serialize to JSON");
2463        assert_eq!(serialized["content"][0]["type"], json!("input_text"));
2464        assert!(serialized.get("id").is_none());
2465    }
2466
2467    #[test]
2468    fn structured_reasoning_with_id_still_converts_for_responses_history() {
2469        let assistant = message::Message::Assistant {
2470            id: Some("msg_123".to_string()),
2471            content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
2472                id: Some("rs_123".to_string()),
2473                content: vec![message::ReasoningContent::Summary(
2474                    "structured summary".to_string(),
2475                )],
2476            })),
2477        };
2478
2479        let converted =
2480            Vec::<Message>::try_from(assistant).expect("structured reasoning should still convert");
2481
2482        assert_eq!(converted.len(), 1);
2483        let Message::Assistant { content, .. } = &converted[0] else {
2484            panic!("expected assistant message");
2485        };
2486        assert!(matches!(
2487            content.first_ref(),
2488            AssistantContentType::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
2489        ));
2490    }
2491
2492    #[test]
2493    fn structured_reasoning_with_id_still_converts_to_input_item() {
2494        let assistant = completion::Message::Assistant {
2495            id: Some("msg_123".to_string()),
2496            content: OneOrMany::one(message::AssistantContent::Reasoning(message::Reasoning {
2497                id: Some("rs_123".to_string()),
2498                content: vec![message::ReasoningContent::Summary(
2499                    "structured summary".to_string(),
2500                )],
2501            })),
2502        };
2503
2504        let converted =
2505            Vec::<InputItem>::try_from(assistant).expect("structured reasoning should convert");
2506
2507        assert_eq!(converted.len(), 1);
2508        assert!(converted[0].role.is_none());
2509        assert!(matches!(
2510            &converted[0].input,
2511            InputContent::Reasoning(OpenAIReasoning { id, .. }) if id == "rs_123"
2512        ));
2513    }
2514
2515    #[test]
2516    fn mocked_second_turn_request_omits_unreplayable_reasoning() {
2517        let request = crate::completion::CompletionRequest {
2518            model: None,
2519            preamble: Some("You are concise.".to_string()),
2520            chat_history: OneOrMany::many(vec![
2521                completion::Message::User {
2522                    content: OneOrMany::one(message::UserContent::Text(Text::new(
2523                        "Think briefly, then answer.",
2524                    ))),
2525                },
2526                completion::Message::Assistant {
2527                    id: Some("msg_123".to_string()),
2528                    content: OneOrMany::many(vec![
2529                        message::AssistantContent::Reasoning(message::Reasoning::new(
2530                            "provider reasoning",
2531                        )),
2532                        message::AssistantContent::Text(Text::new("final answer")),
2533                    ])
2534                    .expect("assistant content should be non-empty"),
2535                },
2536                completion::Message::Assistant {
2537                    id: None,
2538                    content: OneOrMany::many(vec![
2539                        message::AssistantContent::Reasoning(message::Reasoning::new(
2540                            "provider reasoning only",
2541                        )),
2542                        message::AssistantContent::Text(Text::new("")),
2543                    ])
2544                    .expect("assistant content should be non-empty"),
2545                },
2546                completion::Message::User {
2547                    content: OneOrMany::one(message::UserContent::Text(Text::new(
2548                        "/no_think Reply with exactly: OK",
2549                    ))),
2550                },
2551            ])
2552            .expect("history should be non-empty"),
2553            documents: Vec::new(),
2554            tools: Vec::new(),
2555            temperature: None,
2556            max_tokens: Some(64),
2557            tool_choice: None,
2558            additional_params: None,
2559            output_schema: None,
2560        };
2561
2562        let request = CompletionRequest::try_from(("Qwen/Qwen3-4B".to_string(), request))
2563            .expect("request should convert");
2564        let value = serde_json::to_value(&request).expect("request should serialize");
2565        let input = value["input"]
2566            .as_array()
2567            .expect("mocked multi-turn request should serialize input as an array");
2568
2569        assert!(!input.iter().any(|item| {
2570            item.get("type") == Some(&json!("reasoning")) && item.get("id").is_none()
2571        }));
2572        assert!(!input.iter().any(|item| {
2573            item.get("role") == Some(&json!("assistant"))
2574                && item
2575                    .get("content")
2576                    .and_then(Value::as_array)
2577                    .is_some_and(Vec::is_empty)
2578        }));
2579
2580        let assistant_items = input
2581            .iter()
2582            .filter(|item| item.get("role") == Some(&json!("assistant")))
2583            .collect::<Vec<_>>();
2584
2585        assert_eq!(assistant_items.len(), 1);
2586        assert_eq!(assistant_items[0]["content"][0]["type"], "input_text");
2587        assert_eq!(assistant_items[0]["content"][0]["text"], "final answer");
2588    }
2589
2590    #[test]
2591    fn responses_usage_add_preserves_rhs_details_when_lhs_details_are_absent() {
2592        let lhs = ResponsesUsage {
2593            input_tokens: 10,
2594            input_tokens_details: None,
2595            output_tokens: 20,
2596            output_tokens_details: None,
2597            total_tokens: 30,
2598        };
2599        let rhs = ResponsesUsage {
2600            input_tokens: 3,
2601            input_tokens_details: Some(InputTokensDetails { cached_tokens: 2 }),
2602            output_tokens: 5,
2603            output_tokens_details: Some(OutputTokensDetails {
2604                reasoning_tokens: 4,
2605            }),
2606            total_tokens: 8,
2607        };
2608
2609        let usage = lhs + rhs;
2610        let token_usage = usage.token_usage().expect("usage should be present");
2611
2612        assert_eq!(token_usage.input_tokens, 13);
2613        assert_eq!(token_usage.cached_input_tokens, 2);
2614        assert_eq!(token_usage.output_tokens, 25);
2615        assert_eq!(token_usage.reasoning_tokens, 4);
2616        assert_eq!(token_usage.total_tokens, 38);
2617    }
2618
2619    #[test]
2620    fn file_id_document_serializes_as_input_file_content() {
2621        let message = message::Message::User {
2622            content: OneOrMany::one(message::UserContent::Document(message::Document {
2623                data: DocumentSourceKind::FileId("file_abc".to_string()),
2624                media_type: None,
2625                additional_params: None,
2626            })),
2627        };
2628
2629        let converted: Vec<Message> = message.try_into().expect("conversion should succeed");
2630        let Message::User { content, .. } = &converted[0] else {
2631            panic!("expected user message");
2632        };
2633
2634        let json = serde_json::to_value(content.first_ref()).expect("serialize content");
2635
2636        assert_eq!(json["type"], "input_file");
2637        assert_eq!(json["file_id"], "file_abc");
2638        assert!(json.get("file_data").is_none());
2639        assert!(json.get("file_url").is_none());
2640    }
2641
2642    #[test]
2643    fn file_id_document_serializes_as_input_item_content() {
2644        let message = completion::Message::User {
2645            content: OneOrMany::one(message::UserContent::Document(message::Document {
2646                data: DocumentSourceKind::FileId("file_abc".to_string()),
2647                media_type: None,
2648                additional_params: None,
2649            })),
2650        };
2651
2652        let converted: Vec<InputItem> = message.try_into().expect("conversion should succeed");
2653        let json = serde_json::to_value(&converted[0]).expect("serialize input item");
2654
2655        assert_eq!(json["type"], "message");
2656        assert_eq!(json["role"], "user");
2657        assert_eq!(json["content"][0]["type"], "input_file");
2658        assert_eq!(json["content"][0]["file_id"], "file_abc");
2659        assert!(json["content"][0].get("file_data").is_none());
2660        assert!(json["content"][0].get("file_url").is_none());
2661    }
2662}