Skip to main content

rig/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//! let openai_client = rig::providers::openai::Client::from_env();
8//! let model = openai_client.completion_model("gpt-4o").completions_api();
9//! ```
10use super::InputAudio;
11use super::completion::ToolChoice;
12use super::{Client, responses_api::streaming::StreamingCompletionResponse};
13use crate::completion::CompletionError;
14use crate::http_client;
15use crate::http_client::HttpClientExt;
16use crate::json_utils;
17use crate::message::{
18    AudioMediaType, Document, DocumentMediaType, DocumentSourceKind, ImageDetail, MessageError,
19    MimeType, Text,
20};
21use crate::one_or_many::string_or_one_or_many;
22
23use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
24use crate::{OneOrMany, completion, message};
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value};
27use tracing::{Instrument, Level, enabled, info_span};
28
29use std::convert::Infallible;
30use std::ops::Add;
31use std::str::FromStr;
32
33pub mod streaming;
34
35/// The completion request type for OpenAI's Response API: <https://platform.openai.com/docs/api-reference/responses/create>
36/// Intended to be derived from [`crate::completion::request::CompletionRequest`].
37#[derive(Debug, Deserialize, Serialize, Clone)]
38pub struct CompletionRequest {
39    /// Message inputs
40    pub input: OneOrMany<InputItem>,
41    /// The model name
42    pub model: String,
43    /// Instructions (also referred to as preamble, although in other APIs this would be the "system prompt")
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub instructions: Option<String>,
46    /// The maximum number of output tokens.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub max_output_tokens: Option<u64>,
49    /// Toggle to true for streaming responses.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub stream: Option<bool>,
52    /// The temperature. Set higher (up to a max of 1.0) for more creative responses.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub temperature: Option<f64>,
55    /// Whether the LLM should be forced to use a tool before returning a response.
56    /// If none provided, the default option is "auto".
57    #[serde(skip_serializing_if = "Option::is_none")]
58    tool_choice: Option<ToolChoice>,
59    /// The tools you want to use. Currently this is limited to functions, but will be expanded on in future.
60    #[serde(skip_serializing_if = "Vec::is_empty")]
61    pub tools: Vec<ResponsesToolDefinition>,
62    /// Additional parameters
63    #[serde(flatten)]
64    pub additional_parameters: AdditionalParameters,
65}
66
67impl CompletionRequest {
68    pub fn with_structured_outputs<S>(mut self, schema_name: S, schema: serde_json::Value) -> Self
69    where
70        S: Into<String>,
71    {
72        self.additional_parameters.text = Some(TextConfig::structured_output(schema_name, schema));
73
74        self
75    }
76
77    pub fn with_reasoning(mut self, reasoning: Reasoning) -> Self {
78        self.additional_parameters.reasoning = Some(reasoning);
79
80        self
81    }
82}
83
84/// An input item for [`CompletionRequest`].
85#[derive(Debug, Deserialize, Clone)]
86pub struct InputItem {
87    /// The role of an input item/message.
88    /// Input messages should be Some(Role::User), and output messages should be Some(Role::Assistant).
89    /// Everything else should be None.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    role: Option<Role>,
92    /// The input content itself.
93    #[serde(flatten)]
94    input: InputContent,
95}
96
97impl Serialize for InputItem {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: serde::Serializer,
101    {
102        let mut value = serde_json::to_value(&self.input).map_err(serde::ser::Error::custom)?;
103        let map = value.as_object_mut().ok_or_else(|| {
104            serde::ser::Error::custom("Input content must serialize to an object")
105        })?;
106
107        if let Some(role) = &self.role
108            && !map.contains_key("role")
109        {
110            map.insert(
111                "role".to_string(),
112                serde_json::to_value(role).map_err(serde::ser::Error::custom)?,
113            );
114        }
115
116        value.serialize(serializer)
117    }
118}
119
120impl InputItem {
121    pub fn system_message(content: impl Into<String>) -> Self {
122        Self {
123            role: Some(Role::System),
124            input: InputContent::Message(Message::System {
125                content: OneOrMany::one(SystemContent::InputText {
126                    text: content.into(),
127                }),
128                name: None,
129            }),
130        }
131    }
132}
133
134/// Message roles. Used by OpenAI Responses API to determine who created a given message.
135#[derive(Debug, Deserialize, Serialize, Clone)]
136#[serde(rename_all = "lowercase")]
137pub enum Role {
138    User,
139    Assistant,
140    System,
141}
142
143/// The type of content used in an [`InputItem`]. Additionally holds data for each type of input content.
144#[derive(Debug, Deserialize, Serialize, Clone)]
145#[serde(tag = "type", rename_all = "snake_case")]
146pub enum InputContent {
147    Message(Message),
148    Reasoning(OpenAIReasoning),
149    FunctionCall(OutputFunctionCall),
150    FunctionCallOutput(ToolResult),
151}
152
153#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
154pub struct OpenAIReasoning {
155    id: String,
156    pub summary: Vec<ReasoningSummary>,
157    pub encrypted_content: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub status: Option<ToolStatus>,
160}
161
162#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
163#[serde(tag = "type", rename_all = "snake_case")]
164pub enum ReasoningSummary {
165    SummaryText { text: String },
166}
167
168impl ReasoningSummary {
169    fn new(input: &str) -> Self {
170        Self::SummaryText {
171            text: input.to_string(),
172        }
173    }
174
175    pub fn text(&self) -> String {
176        let ReasoningSummary::SummaryText { text } = self;
177        text.clone()
178    }
179}
180
181/// A tool result.
182#[derive(Debug, Deserialize, Serialize, Clone)]
183pub struct ToolResult {
184    /// The call ID of a tool (this should be linked to the call ID for a tool call, otherwise an error will be received)
185    call_id: String,
186    /// The result of a tool call.
187    output: String,
188    /// The status of a tool call (if used in a completion request, this should always be Completed)
189    status: ToolStatus,
190}
191
192impl From<Message> for InputItem {
193    fn from(value: Message) -> Self {
194        match value {
195            Message::User { .. } => Self {
196                role: Some(Role::User),
197                input: InputContent::Message(value),
198            },
199            Message::Assistant { ref content, .. } => {
200                let role = if content
201                    .iter()
202                    .any(|x| matches!(x, AssistantContentType::Reasoning(_)))
203                {
204                    None
205                } else {
206                    Some(Role::Assistant)
207                };
208                Self {
209                    role,
210                    input: InputContent::Message(value),
211                }
212            }
213            Message::System { .. } => Self {
214                role: Some(Role::System),
215                input: InputContent::Message(value),
216            },
217            Message::ToolResult {
218                tool_call_id,
219                output,
220            } => Self {
221                role: None,
222                input: InputContent::FunctionCallOutput(ToolResult {
223                    call_id: tool_call_id,
224                    output,
225                    status: ToolStatus::Completed,
226                }),
227            },
228        }
229    }
230}
231
232impl TryFrom<crate::completion::Message> for Vec<InputItem> {
233    type Error = CompletionError;
234
235    fn try_from(value: crate::completion::Message) -> Result<Self, Self::Error> {
236        match value {
237            crate::completion::Message::User { content } => {
238                let mut items = Vec::new();
239
240                for user_content in content {
241                    match user_content {
242                        crate::message::UserContent::Text(Text { text }) => {
243                            items.push(InputItem {
244                                role: Some(Role::User),
245                                input: InputContent::Message(Message::User {
246                                    content: OneOrMany::one(UserContent::InputText { text }),
247                                    name: None,
248                                }),
249                            });
250                        }
251                        crate::message::UserContent::ToolResult(
252                            crate::completion::message::ToolResult {
253                                call_id,
254                                content: tool_content,
255                                ..
256                            },
257                        ) => {
258                            for tool_result_content in tool_content {
259                                let crate::completion::message::ToolResultContent::Text(Text {
260                                    text,
261                                }) = tool_result_content
262                                else {
263                                    return Err(CompletionError::ProviderError(
264                                        "This thing only supports text!".to_string(),
265                                    ));
266                                };
267                                // let output = serde_json::from_str(&text)?;
268                                items.push(InputItem {
269                                    role: None,
270                                    input: InputContent::FunctionCallOutput(ToolResult {
271                                        call_id: require_call_id(call_id.clone(), "Tool result")?,
272                                        output: text,
273                                        status: ToolStatus::Completed,
274                                    }),
275                                });
276                            }
277                        }
278                        crate::message::UserContent::Document(Document {
279                            data,
280                            media_type: Some(DocumentMediaType::PDF),
281                            ..
282                        }) => {
283                            let (file_data, file_url) = match data {
284                                DocumentSourceKind::Base64(data) => {
285                                    (Some(format!("data:application/pdf;base64,{data}")), None)
286                                }
287                                DocumentSourceKind::Url(url) => (None, Some(url)),
288                                DocumentSourceKind::Raw(_) => {
289                                    return Err(CompletionError::RequestError(
290                                        "Raw file data not supported, encode as base64 first"
291                                            .into(),
292                                    ));
293                                }
294                                doc => {
295                                    return Err(CompletionError::RequestError(
296                                        format!("Unsupported document type: {doc}").into(),
297                                    ));
298                                }
299                            };
300
301                            items.push(InputItem {
302                                role: Some(Role::User),
303                                input: InputContent::Message(Message::User {
304                                    content: OneOrMany::one(UserContent::InputFile {
305                                        file_data,
306                                        file_url,
307                                        filename: Some("document.pdf".to_string()),
308                                    }),
309                                    name: None,
310                                }),
311                            })
312                        }
313                        crate::message::UserContent::Document(Document {
314                            data:
315                                DocumentSourceKind::Base64(text) | DocumentSourceKind::String(text),
316                            ..
317                        }) => items.push(InputItem {
318                            role: Some(Role::User),
319                            input: InputContent::Message(Message::User {
320                                content: OneOrMany::one(UserContent::InputText { text }),
321                                name: None,
322                            }),
323                        }),
324                        crate::message::UserContent::Image(crate::message::Image {
325                            data,
326                            media_type,
327                            detail,
328                            ..
329                        }) => {
330                            let url = match data {
331                                DocumentSourceKind::Base64(data) => {
332                                    let media_type = if let Some(media_type) = media_type {
333                                        media_type.to_mime_type().to_string()
334                                    } else {
335                                        String::new()
336                                    };
337                                    format!("data:{media_type};base64,{data}")
338                                }
339                                DocumentSourceKind::Url(url) => url,
340                                DocumentSourceKind::Raw(_) => {
341                                    return Err(CompletionError::RequestError(
342                                        "Raw file data not supported, encode as base64 first"
343                                            .into(),
344                                    ));
345                                }
346                                doc => {
347                                    return Err(CompletionError::RequestError(
348                                        format!("Unsupported document type: {doc}").into(),
349                                    ));
350                                }
351                            };
352                            items.push(InputItem {
353                                role: Some(Role::User),
354                                input: InputContent::Message(Message::User {
355                                    content: OneOrMany::one(UserContent::InputImage {
356                                        image_url: url,
357                                        detail: detail.unwrap_or_default(),
358                                    }),
359                                    name: None,
360                                }),
361                            });
362                        }
363                        message => {
364                            return Err(CompletionError::ProviderError(format!(
365                                "Unsupported message: {message:?}"
366                            )));
367                        }
368                    }
369                }
370
371                Ok(items)
372            }
373            crate::completion::Message::Assistant { id, content } => {
374                let mut reasoning_items = Vec::new();
375                let mut other_items = Vec::new();
376
377                for assistant_content in content {
378                    match assistant_content {
379                        crate::message::AssistantContent::Text(Text { text }) => {
380                            let id = id.as_ref().unwrap_or(&String::default()).clone();
381                            other_items.push(InputItem {
382                                role: Some(Role::Assistant),
383                                input: InputContent::Message(Message::Assistant {
384                                    content: OneOrMany::one(AssistantContentType::Text(
385                                        AssistantContent::OutputText(Text { text }),
386                                    )),
387                                    id,
388                                    name: None,
389                                    status: ToolStatus::Completed,
390                                }),
391                            });
392                        }
393                        crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
394                            id: tool_id,
395                            call_id,
396                            function,
397                            ..
398                        }) => {
399                            other_items.push(InputItem {
400                                role: None,
401                                input: InputContent::FunctionCall(OutputFunctionCall {
402                                    arguments: function.arguments,
403                                    call_id: require_call_id(call_id, "Assistant tool call")?,
404                                    id: tool_id,
405                                    name: function.name,
406                                    status: ToolStatus::Completed,
407                                }),
408                            });
409                        }
410                        crate::message::AssistantContent::Reasoning(reasoning) => {
411                            let openai_reasoning = openai_reasoning_from_core(&reasoning)
412                                .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
413                            reasoning_items.push(InputItem {
414                                role: None,
415                                input: InputContent::Reasoning(openai_reasoning),
416                            });
417                        }
418                        crate::message::AssistantContent::Image(_) => {
419                            return Err(CompletionError::ProviderError(
420                                "Assistant image content is not supported in OpenAI Responses API"
421                                    .to_string(),
422                            ));
423                        }
424                    }
425                }
426
427                let mut items = reasoning_items;
428                items.extend(other_items);
429                Ok(items)
430            }
431        }
432    }
433}
434
435impl From<OneOrMany<String>> for Vec<ReasoningSummary> {
436    fn from(value: OneOrMany<String>) -> Self {
437        value.iter().map(|x| ReasoningSummary::new(x)).collect()
438    }
439}
440
441fn require_call_id(call_id: Option<String>, context: &str) -> Result<String, CompletionError> {
442    call_id.ok_or_else(|| {
443        CompletionError::RequestError(
444            format!("{context} `call_id` is required for OpenAI Responses API").into(),
445        )
446    })
447}
448
449fn openai_reasoning_from_core(
450    reasoning: &crate::message::Reasoning,
451) -> Result<OpenAIReasoning, MessageError> {
452    let id = reasoning.id.clone().ok_or_else(|| {
453        MessageError::ConversionError(
454            "An OpenAI-generated ID is required when using OpenAI reasoning items".to_string(),
455        )
456    })?;
457    let mut summary = Vec::new();
458    let mut encrypted_content = None;
459    for content in &reasoning.content {
460        match content {
461            crate::message::ReasoningContent::Text { text, .. }
462            | crate::message::ReasoningContent::Summary(text) => {
463                summary.push(ReasoningSummary::new(text));
464            }
465            // OpenAI reasoning input has one opaque payload field; preserve either
466            // encrypted or redacted blocks there, preferring the first one seen.
467            crate::message::ReasoningContent::Encrypted(data)
468            | crate::message::ReasoningContent::Redacted { data } => {
469                encrypted_content.get_or_insert_with(|| data.clone());
470            }
471        }
472    }
473
474    Ok(OpenAIReasoning {
475        id,
476        summary,
477        encrypted_content,
478        status: None,
479    })
480}
481
482/// The definition of a tool response, repurposed for OpenAI's Responses API.
483#[derive(Debug, Deserialize, Serialize, Clone)]
484pub struct ResponsesToolDefinition {
485    /// Tool name
486    pub name: String,
487    /// 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).
488    pub parameters: serde_json::Value,
489    /// Whether to use strict mode. Enabled by default as it allows for improved efficiency.
490    pub strict: bool,
491    /// The type of tool. This should always be "function".
492    #[serde(rename = "type")]
493    pub kind: String,
494    /// Tool description.
495    pub description: String,
496}
497
498impl From<completion::ToolDefinition> for ResponsesToolDefinition {
499    fn from(value: completion::ToolDefinition) -> Self {
500        let completion::ToolDefinition {
501            name,
502            mut parameters,
503            description,
504        } = value;
505
506        super::sanitize_schema(&mut parameters);
507
508        Self {
509            name,
510            parameters,
511            description,
512            kind: "function".to_string(),
513            strict: true,
514        }
515    }
516}
517
518/// Token usage.
519/// 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.
520#[derive(Clone, Debug, Serialize, Deserialize)]
521pub struct ResponsesUsage {
522    /// Input tokens
523    pub input_tokens: u64,
524    /// In-depth detail on input tokens (cached tokens)
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub input_tokens_details: Option<InputTokensDetails>,
527    /// Output tokens
528    pub output_tokens: u64,
529    /// In-depth detail on output tokens (reasoning tokens)
530    pub output_tokens_details: OutputTokensDetails,
531    /// Total tokens used (for a given prompt)
532    pub total_tokens: u64,
533}
534
535impl ResponsesUsage {
536    /// Create a new ResponsesUsage instance
537    pub(crate) fn new() -> Self {
538        Self {
539            input_tokens: 0,
540            input_tokens_details: Some(InputTokensDetails::new()),
541            output_tokens: 0,
542            output_tokens_details: OutputTokensDetails::new(),
543            total_tokens: 0,
544        }
545    }
546}
547
548impl Add for ResponsesUsage {
549    type Output = Self;
550
551    fn add(self, rhs: Self) -> Self::Output {
552        let input_tokens = self.input_tokens + rhs.input_tokens;
553        let input_tokens_details = self.input_tokens_details.map(|lhs| {
554            if let Some(tokens) = rhs.input_tokens_details {
555                lhs + tokens
556            } else {
557                lhs
558            }
559        });
560        let output_tokens = self.output_tokens + rhs.output_tokens;
561        let output_tokens_details = self.output_tokens_details + rhs.output_tokens_details;
562        let total_tokens = self.total_tokens + rhs.total_tokens;
563        Self {
564            input_tokens,
565            input_tokens_details,
566            output_tokens,
567            output_tokens_details,
568            total_tokens,
569        }
570    }
571}
572
573/// In-depth details on input tokens.
574#[derive(Clone, Debug, Serialize, Deserialize)]
575pub struct InputTokensDetails {
576    /// Cached tokens from OpenAI
577    pub cached_tokens: u64,
578}
579
580impl InputTokensDetails {
581    pub(crate) fn new() -> Self {
582        Self { cached_tokens: 0 }
583    }
584}
585
586impl Add for InputTokensDetails {
587    type Output = Self;
588    fn add(self, rhs: Self) -> Self::Output {
589        Self {
590            cached_tokens: self.cached_tokens + rhs.cached_tokens,
591        }
592    }
593}
594
595/// In-depth details on output tokens.
596#[derive(Clone, Debug, Serialize, Deserialize)]
597pub struct OutputTokensDetails {
598    /// Reasoning tokens
599    pub reasoning_tokens: u64,
600}
601
602impl OutputTokensDetails {
603    pub(crate) fn new() -> Self {
604        Self {
605            reasoning_tokens: 0,
606        }
607    }
608}
609
610impl Add for OutputTokensDetails {
611    type Output = Self;
612    fn add(self, rhs: Self) -> Self::Output {
613        Self {
614            reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
615        }
616    }
617}
618
619/// Occasionally, when using OpenAI's Responses API you may get an incomplete response. This struct holds the reason as to why it happened.
620#[derive(Clone, Debug, Default, Serialize, Deserialize)]
621pub struct IncompleteDetailsReason {
622    /// The reason for an incomplete [`CompletionResponse`].
623    pub reason: String,
624}
625
626/// A response error from OpenAI's Response API.
627#[derive(Clone, Debug, Default, Serialize, Deserialize)]
628pub struct ResponseError {
629    /// Error code
630    pub code: String,
631    /// Error message
632    pub message: String,
633}
634
635/// A response object as an enum (ensures type validation)
636#[derive(Clone, Debug, Deserialize, Serialize)]
637#[serde(rename_all = "snake_case")]
638pub enum ResponseObject {
639    Response,
640}
641
642/// The response status as an enum (ensures type validation)
643#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
644#[serde(rename_all = "snake_case")]
645pub enum ResponseStatus {
646    InProgress,
647    Completed,
648    Failed,
649    Cancelled,
650    Queued,
651    Incomplete,
652}
653
654/// Attempt to try and create a `NewCompletionRequest` from a model name and [`crate::completion::CompletionRequest`]
655impl TryFrom<(String, crate::completion::CompletionRequest)> for CompletionRequest {
656    type Error = CompletionError;
657    fn try_from(
658        (model, req): (String, crate::completion::CompletionRequest),
659    ) -> Result<Self, Self::Error> {
660        let model = req.model.clone().unwrap_or(model);
661        let input = {
662            let mut partial_history = vec![];
663            if let Some(docs) = req.normalized_documents() {
664                partial_history.push(docs);
665            }
666            partial_history.extend(req.chat_history);
667
668            // Initialize full history with preamble (or empty if non-existent)
669            // Some "Responses API compatible" providers don't support `instructions` field
670            // so we need to add a system message until further notice
671            let mut full_history: Vec<InputItem> = if let Some(content) = req.preamble {
672                vec![InputItem::system_message(content)]
673            } else {
674                Vec::new()
675            };
676
677            for history_item in partial_history {
678                full_history.extend(<Vec<InputItem>>::try_from(history_item)?);
679            }
680
681            full_history
682        };
683
684        let input = OneOrMany::many(input).map_err(|_| {
685            CompletionError::RequestError(
686                "OpenAI Responses request input must contain at least one item".into(),
687            )
688        })?;
689
690        let stream = req
691            .additional_params
692            .clone()
693            .unwrap_or(Value::Null)
694            .as_bool();
695
696        let mut additional_parameters = if let Some(map) = req.additional_params {
697            serde_json::from_value::<AdditionalParameters>(map).map_err(|err| {
698                CompletionError::RequestError(
699                    format!("Invalid OpenAI Responses additional_params payload: {err}").into(),
700                )
701            })?
702        } else {
703            // If there's no additional parameters, initialise an empty object
704            AdditionalParameters::default()
705        };
706        if additional_parameters.reasoning.is_some() {
707            let include = additional_parameters.include.get_or_insert_with(Vec::new);
708            if !include
709                .iter()
710                .any(|item| matches!(item, Include::ReasoningEncryptedContent))
711            {
712                include.push(Include::ReasoningEncryptedContent);
713            }
714        }
715
716        // Apply output_schema as structured output if not already configured via additional_params
717        if additional_parameters.text.is_none()
718            && let Some(schema) = req.output_schema
719        {
720            let name = schema
721                .as_object()
722                .and_then(|o| o.get("title"))
723                .and_then(|v| v.as_str())
724                .unwrap_or("response_schema")
725                .to_string();
726            let mut schema_value = schema.to_value();
727            super::sanitize_schema(&mut schema_value);
728            additional_parameters.text = Some(TextConfig::structured_output(name, schema_value));
729        }
730
731        let tool_choice = req.tool_choice.map(ToolChoice::try_from).transpose()?;
732
733        Ok(Self {
734            input,
735            model,
736            instructions: None, // is currently None due to lack of support in compliant providers
737            max_output_tokens: req.max_tokens,
738            stream,
739            tool_choice,
740            tools: req
741                .tools
742                .into_iter()
743                .map(ResponsesToolDefinition::from)
744                .collect(),
745            temperature: req.temperature,
746            additional_parameters,
747        })
748    }
749}
750
751/// The completion model struct for OpenAI's response API.
752#[derive(Clone)]
753pub struct ResponsesCompletionModel<T = reqwest::Client> {
754    /// The OpenAI client
755    pub(crate) client: Client<T>,
756    /// Name of the model (e.g.: gpt-3.5-turbo-1106)
757    pub model: String,
758}
759
760impl<T> ResponsesCompletionModel<T>
761where
762    T: HttpClientExt + Clone + Default + std::fmt::Debug + 'static,
763{
764    /// Creates a new [`ResponsesCompletionModel`].
765    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
766        Self {
767            client,
768            model: model.into(),
769        }
770    }
771
772    pub fn with_model(client: Client<T>, model: &str) -> Self {
773        Self {
774            client,
775            model: model.to_string(),
776        }
777    }
778
779    /// Use the Completions API instead of Responses.
780    pub fn completions_api(self) -> crate::providers::openai::completion::CompletionModel<T> {
781        super::completion::CompletionModel::with_model(self.client.completions_api(), &self.model)
782    }
783
784    /// Attempt to create a completion request from [`crate::completion::CompletionRequest`].
785    pub(crate) fn create_completion_request(
786        &self,
787        completion_request: crate::completion::CompletionRequest,
788    ) -> Result<CompletionRequest, CompletionError> {
789        let req = CompletionRequest::try_from((self.model.clone(), completion_request))?;
790
791        Ok(req)
792    }
793}
794
795/// The standard response format from OpenAI's Responses API.
796#[derive(Clone, Debug, Serialize, Deserialize)]
797pub struct CompletionResponse {
798    /// The ID of a completion response.
799    pub id: String,
800    /// The type of the object.
801    pub object: ResponseObject,
802    /// The time at which a given response has been created, in seconds from the UNIX epoch (01/01/1970 00:00:00).
803    pub created_at: u64,
804    /// The status of the response.
805    pub status: ResponseStatus,
806    /// Response error (optional)
807    pub error: Option<ResponseError>,
808    /// Incomplete response details (optional)
809    pub incomplete_details: Option<IncompleteDetailsReason>,
810    /// System prompt/preamble
811    pub instructions: Option<String>,
812    /// The maximum number of tokens the model should output
813    pub max_output_tokens: Option<u64>,
814    /// The model name
815    pub model: String,
816    /// Token usage
817    pub usage: Option<ResponsesUsage>,
818    /// The model output (messages, etc will go here)
819    pub output: Vec<Output>,
820    /// Tools
821    #[serde(default)]
822    pub tools: Vec<ResponsesToolDefinition>,
823    /// Additional parameters
824    #[serde(flatten)]
825    pub additional_parameters: AdditionalParameters,
826}
827
828/// Additional parameters for the completion request type for OpenAI's Response API: <https://platform.openai.com/docs/api-reference/responses/create>
829/// Intended to be derived from [`crate::completion::request::CompletionRequest`].
830#[derive(Clone, Debug, Deserialize, Serialize, Default)]
831pub struct AdditionalParameters {
832    /// Whether or not a given model task should run in the background (ie a detached process).
833    #[serde(skip_serializing_if = "Option::is_none")]
834    pub background: Option<bool>,
835    /// The text response format. This is where you would add structured outputs (if you want them).
836    #[serde(skip_serializing_if = "Option::is_none")]
837    pub text: Option<TextConfig>,
838    /// 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!
839    #[serde(skip_serializing_if = "Option::is_none")]
840    pub include: Option<Vec<Include>>,
841    /// `top_p`. Mutually exclusive with the `temperature` argument.
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub top_p: Option<f64>,
844    /// Whether or not the response should be truncated.
845    #[serde(skip_serializing_if = "Option::is_none")]
846    pub truncation: Option<TruncationStrategy>,
847    /// The username of the user (that you want to use).
848    #[serde(skip_serializing_if = "Option::is_none")]
849    pub user: Option<String>,
850    /// Any additional metadata you'd like to add. This will additionally be returned by the response.
851    #[serde(skip_serializing_if = "Map::is_empty", default)]
852    pub metadata: serde_json::Map<String, serde_json::Value>,
853    /// Whether or not you want tool calls to run in parallel.
854    #[serde(skip_serializing_if = "Option::is_none")]
855    pub parallel_tool_calls: Option<bool>,
856    /// Previous response ID. If you are not sending a full conversation, this can help to track the message flow.
857    #[serde(skip_serializing_if = "Option::is_none")]
858    pub previous_response_id: Option<String>,
859    /// Add thinking/reasoning to your response. The response will be emitted as a list member of the `output` field.
860    #[serde(skip_serializing_if = "Option::is_none")]
861    pub reasoning: Option<Reasoning>,
862    /// The service tier you're using.
863    #[serde(skip_serializing_if = "Option::is_none")]
864    pub service_tier: Option<OpenAIServiceTier>,
865    /// Whether or not to store the response for later retrieval by API.
866    #[serde(skip_serializing_if = "Option::is_none")]
867    pub store: Option<bool>,
868}
869
870impl AdditionalParameters {
871    pub fn to_json(self) -> serde_json::Value {
872        serde_json::to_value(self).unwrap_or_else(|_| serde_json::Value::Object(Map::new()))
873    }
874}
875
876/// The truncation strategy.
877/// 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.
878/// Otherwise, does nothing (and is disabled by default).
879#[derive(Clone, Debug, Default, Serialize, Deserialize)]
880#[serde(rename_all = "snake_case")]
881pub enum TruncationStrategy {
882    Auto,
883    #[default]
884    Disabled,
885}
886
887/// The model output format configuration.
888/// You can either have plain text by default, or attach a JSON schema for the purposes of structured outputs.
889#[derive(Clone, Debug, Serialize, Deserialize)]
890pub struct TextConfig {
891    pub format: TextFormat,
892}
893
894impl TextConfig {
895    pub(crate) fn structured_output<S>(name: S, schema: serde_json::Value) -> Self
896    where
897        S: Into<String>,
898    {
899        Self {
900            format: TextFormat::JsonSchema(StructuredOutputsInput {
901                name: name.into(),
902                schema,
903                strict: true,
904            }),
905        }
906    }
907}
908
909/// The text format (contained by [`TextConfig`]).
910/// You can either have plain text by default, or attach a JSON schema for the purposes of structured outputs.
911#[derive(Clone, Debug, Serialize, Deserialize, Default)]
912#[serde(tag = "type")]
913#[serde(rename_all = "snake_case")]
914pub enum TextFormat {
915    JsonSchema(StructuredOutputsInput),
916    #[default]
917    Text,
918}
919
920/// The inputs required for adding structured outputs.
921#[derive(Clone, Debug, Serialize, Deserialize)]
922pub struct StructuredOutputsInput {
923    /// The name of your schema.
924    pub name: String,
925    /// 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>.
926    pub schema: serde_json::Value,
927    /// 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.
928    pub strict: bool,
929}
930
931/// Add reasoning to a [`CompletionRequest`].
932#[derive(Clone, Debug, Default, Serialize, Deserialize)]
933pub struct Reasoning {
934    /// How much effort you want the model to put into thinking/reasoning.
935    pub effort: Option<ReasoningEffort>,
936    /// How much effort you want the model to put into writing the reasoning summary.
937    #[serde(skip_serializing_if = "Option::is_none")]
938    pub summary: Option<ReasoningSummaryLevel>,
939}
940
941impl Reasoning {
942    /// Creates a new Reasoning instantiation (with empty values).
943    pub fn new() -> Self {
944        Self {
945            effort: None,
946            summary: None,
947        }
948    }
949
950    /// Adds reasoning effort.
951    pub fn with_effort(mut self, reasoning_effort: ReasoningEffort) -> Self {
952        self.effort = Some(reasoning_effort);
953
954        self
955    }
956
957    /// Adds summary level (how detailed the reasoning summary will be).
958    pub fn with_summary_level(mut self, reasoning_summary_level: ReasoningSummaryLevel) -> Self {
959        self.summary = Some(reasoning_summary_level);
960
961        self
962    }
963}
964
965/// The billing service tier that will be used. On auto by default.
966#[derive(Clone, Debug, Default, Serialize, Deserialize)]
967#[serde(rename_all = "snake_case")]
968pub enum OpenAIServiceTier {
969    #[default]
970    Auto,
971    Default,
972    Flex,
973}
974
975/// The amount of reasoning effort that will be used by a given model.
976#[derive(Clone, Debug, Default, Serialize, Deserialize)]
977#[serde(rename_all = "snake_case")]
978pub enum ReasoningEffort {
979    None,
980    Minimal,
981    Low,
982    #[default]
983    Medium,
984    High,
985    Xhigh,
986}
987
988/// The amount of effort that will go into a reasoning summary by a given model.
989#[derive(Clone, Debug, Default, Serialize, Deserialize)]
990#[serde(rename_all = "snake_case")]
991pub enum ReasoningSummaryLevel {
992    #[default]
993    Auto,
994    Concise,
995    Detailed,
996}
997
998/// Results to additionally include in the OpenAI Responses API.
999/// Note that most of these are currently unsupported, but have been added for completeness.
1000#[derive(Clone, Debug, Deserialize, Serialize)]
1001pub enum Include {
1002    #[serde(rename = "file_search_call.results")]
1003    FileSearchCallResults,
1004    #[serde(rename = "message.input_image.image_url")]
1005    MessageInputImageImageUrl,
1006    #[serde(rename = "computer_call.output.image_url")]
1007    ComputerCallOutputOutputImageUrl,
1008    #[serde(rename = "reasoning.encrypted_content")]
1009    ReasoningEncryptedContent,
1010    #[serde(rename = "code_interpreter_call.outputs")]
1011    CodeInterpreterCallOutputs,
1012}
1013
1014/// A currently non-exhaustive list of output types.
1015#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1016#[serde(tag = "type")]
1017#[serde(rename_all = "snake_case")]
1018pub enum Output {
1019    Message(OutputMessage),
1020    #[serde(alias = "function_call")]
1021    FunctionCall(OutputFunctionCall),
1022    Reasoning {
1023        id: String,
1024        summary: Vec<ReasoningSummary>,
1025        #[serde(default)]
1026        encrypted_content: Option<String>,
1027        #[serde(default)]
1028        status: Option<ToolStatus>,
1029    },
1030}
1031
1032impl From<Output> for Vec<completion::AssistantContent> {
1033    fn from(value: Output) -> Self {
1034        let res: Vec<completion::AssistantContent> = match value {
1035            Output::Message(OutputMessage { content, .. }) => content
1036                .into_iter()
1037                .map(completion::AssistantContent::from)
1038                .collect(),
1039            Output::FunctionCall(OutputFunctionCall {
1040                id,
1041                arguments,
1042                call_id,
1043                name,
1044                ..
1045            }) => vec![completion::AssistantContent::tool_call_with_call_id(
1046                id, call_id, name, arguments,
1047            )],
1048            Output::Reasoning {
1049                id,
1050                summary,
1051                encrypted_content,
1052                ..
1053            } => {
1054                let mut content = summary
1055                    .into_iter()
1056                    .map(|summary| match summary {
1057                        ReasoningSummary::SummaryText { text } => {
1058                            message::ReasoningContent::Summary(text)
1059                        }
1060                    })
1061                    .collect::<Vec<_>>();
1062                if let Some(encrypted_content) = encrypted_content {
1063                    content.push(message::ReasoningContent::Encrypted(encrypted_content));
1064                }
1065                vec![completion::AssistantContent::Reasoning(
1066                    message::Reasoning {
1067                        id: Some(id),
1068                        content,
1069                    },
1070                )]
1071            }
1072        };
1073
1074        res
1075    }
1076}
1077
1078#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1079pub struct OutputReasoning {
1080    id: String,
1081    summary: Vec<ReasoningSummary>,
1082    status: ToolStatus,
1083}
1084
1085/// 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.
1086#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1087pub struct OutputFunctionCall {
1088    pub id: String,
1089    #[serde(with = "json_utils::stringified_json")]
1090    pub arguments: serde_json::Value,
1091    pub call_id: String,
1092    pub name: String,
1093    pub status: ToolStatus,
1094}
1095
1096/// The status of a given tool.
1097#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1098#[serde(rename_all = "snake_case")]
1099pub enum ToolStatus {
1100    InProgress,
1101    Completed,
1102    Incomplete,
1103}
1104
1105/// An output message from OpenAI's Responses API.
1106#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1107pub struct OutputMessage {
1108    /// The message ID. Must be included when sending the message back to OpenAI
1109    pub id: String,
1110    /// The role (currently only Assistant is available as this struct is only created when receiving an LLM message as a response)
1111    pub role: OutputRole,
1112    /// The status of the response
1113    pub status: ResponseStatus,
1114    /// The actual message content
1115    pub content: Vec<AssistantContent>,
1116}
1117
1118/// The role of an output message.
1119#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
1120#[serde(rename_all = "snake_case")]
1121pub enum OutputRole {
1122    Assistant,
1123}
1124
1125impl<T> completion::CompletionModel for ResponsesCompletionModel<T>
1126where
1127    T: HttpClientExt
1128        + Clone
1129        + std::fmt::Debug
1130        + Default
1131        + WasmCompatSend
1132        + WasmCompatSync
1133        + 'static,
1134{
1135    type Response = CompletionResponse;
1136    type StreamingResponse = StreamingCompletionResponse;
1137
1138    type Client = super::Client<T>;
1139
1140    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
1141        Self::new(client.clone(), model)
1142    }
1143
1144    async fn completion(
1145        &self,
1146        completion_request: crate::completion::CompletionRequest,
1147    ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
1148        let span = if tracing::Span::current().is_disabled() {
1149            info_span!(
1150                target: "rig::completions",
1151                "chat",
1152                gen_ai.operation.name = "chat",
1153                gen_ai.provider.name = tracing::field::Empty,
1154                gen_ai.request.model = tracing::field::Empty,
1155                gen_ai.response.id = tracing::field::Empty,
1156                gen_ai.response.model = tracing::field::Empty,
1157                gen_ai.usage.output_tokens = tracing::field::Empty,
1158                gen_ai.usage.input_tokens = tracing::field::Empty,
1159                gen_ai.input.messages = tracing::field::Empty,
1160                gen_ai.output.messages = tracing::field::Empty,
1161            )
1162        } else {
1163            tracing::Span::current()
1164        };
1165
1166        span.record("gen_ai.provider.name", "openai");
1167        span.record("gen_ai.request.model", &self.model);
1168        let request = self.create_completion_request(completion_request)?;
1169        let body = serde_json::to_vec(&request)?;
1170
1171        if enabled!(Level::TRACE) {
1172            tracing::trace!(
1173                target: "rig::completions",
1174                "OpenAI Responses completion request: {request}",
1175                request = serde_json::to_string_pretty(&request)?
1176            );
1177        }
1178
1179        let req = self
1180            .client
1181            .post("/responses")?
1182            .body(body)
1183            .map_err(|e| CompletionError::HttpError(e.into()))?;
1184
1185        async move {
1186            let response = self.client.send(req).await?;
1187
1188            if response.status().is_success() {
1189                let t = http_client::text(response).await?;
1190                let response = serde_json::from_str::<Self::Response>(&t)?;
1191                let span = tracing::Span::current();
1192                span.record("gen_ai.response.id", &response.id);
1193                span.record("gen_ai.response.model", &response.model);
1194                if let Some(ref usage) = response.usage {
1195                    span.record("gen_ai.usage.output_tokens", usage.output_tokens);
1196                    span.record("gen_ai.usage.input_tokens", usage.input_tokens);
1197                }
1198                if enabled!(Level::TRACE) {
1199                    tracing::trace!(
1200                        target: "rig::completions",
1201                        "OpenAI Responses completion response: {response}",
1202                        response = serde_json::to_string_pretty(&response)?
1203                    );
1204                }
1205                response.try_into()
1206            } else {
1207                let text = http_client::text(response).await?;
1208                Err(CompletionError::ProviderError(text))
1209            }
1210        }
1211        .instrument(span)
1212        .await
1213    }
1214
1215    async fn stream(
1216        &self,
1217        request: crate::completion::CompletionRequest,
1218    ) -> Result<
1219        crate::streaming::StreamingCompletionResponse<Self::StreamingResponse>,
1220        CompletionError,
1221    > {
1222        ResponsesCompletionModel::stream(self, request).await
1223    }
1224}
1225
1226impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
1227    type Error = CompletionError;
1228
1229    fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
1230        if response.output.is_empty() {
1231            return Err(CompletionError::ResponseError(
1232                "Response contained no parts".to_owned(),
1233            ));
1234        }
1235
1236        // Extract the msg_ ID from the first Output::Message item
1237        let message_id = response.output.iter().find_map(|item| match item {
1238            Output::Message(msg) => Some(msg.id.clone()),
1239            _ => None,
1240        });
1241
1242        let content: Vec<completion::AssistantContent> = response
1243            .output
1244            .iter()
1245            .cloned()
1246            .flat_map(<Vec<completion::AssistantContent>>::from)
1247            .collect();
1248
1249        let choice = OneOrMany::many(content).map_err(|_| {
1250            CompletionError::ResponseError(
1251                "Response contained no message or tool call (empty)".to_owned(),
1252            )
1253        })?;
1254
1255        let usage = response
1256            .usage
1257            .as_ref()
1258            .map(|usage| completion::Usage {
1259                input_tokens: usage.input_tokens,
1260                output_tokens: usage.output_tokens,
1261                total_tokens: usage.total_tokens,
1262                cached_input_tokens: usage
1263                    .input_tokens_details
1264                    .as_ref()
1265                    .map(|d| d.cached_tokens)
1266                    .unwrap_or(0),
1267            })
1268            .unwrap_or_default();
1269
1270        Ok(completion::CompletionResponse {
1271            choice,
1272            usage,
1273            raw_response: response,
1274            message_id,
1275        })
1276    }
1277}
1278
1279/// An OpenAI Responses API message.
1280#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1281#[serde(tag = "role", rename_all = "lowercase")]
1282pub enum Message {
1283    #[serde(alias = "developer")]
1284    System {
1285        #[serde(deserialize_with = "string_or_one_or_many")]
1286        content: OneOrMany<SystemContent>,
1287        #[serde(skip_serializing_if = "Option::is_none")]
1288        name: Option<String>,
1289    },
1290    User {
1291        #[serde(deserialize_with = "string_or_one_or_many")]
1292        content: OneOrMany<UserContent>,
1293        #[serde(skip_serializing_if = "Option::is_none")]
1294        name: Option<String>,
1295    },
1296    Assistant {
1297        content: OneOrMany<AssistantContentType>,
1298        #[serde(skip_serializing_if = "String::is_empty")]
1299        id: String,
1300        #[serde(skip_serializing_if = "Option::is_none")]
1301        name: Option<String>,
1302        status: ToolStatus,
1303    },
1304    #[serde(rename = "tool")]
1305    ToolResult {
1306        tool_call_id: String,
1307        output: String,
1308    },
1309}
1310
1311/// The type of a tool result content item.
1312#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
1313#[serde(rename_all = "lowercase")]
1314pub enum ToolResultContentType {
1315    #[default]
1316    Text,
1317}
1318
1319impl Message {
1320    pub fn system(content: &str) -> Self {
1321        Message::System {
1322            content: OneOrMany::one(content.to_owned().into()),
1323            name: None,
1324        }
1325    }
1326}
1327
1328/// Text assistant content.
1329/// Note that the text type in comparison to the Completions API is actually `output_text` rather than `text`.
1330#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1331#[serde(tag = "type", rename_all = "snake_case")]
1332pub enum AssistantContent {
1333    OutputText(Text),
1334    Refusal { refusal: String },
1335}
1336
1337impl From<AssistantContent> for completion::AssistantContent {
1338    fn from(value: AssistantContent) -> Self {
1339        match value {
1340            AssistantContent::Refusal { refusal } => {
1341                completion::AssistantContent::Text(Text { text: refusal })
1342            }
1343            AssistantContent::OutputText(Text { text }) => {
1344                completion::AssistantContent::Text(Text { text })
1345            }
1346        }
1347    }
1348}
1349
1350/// The type of assistant content.
1351#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1352#[serde(untagged)]
1353pub enum AssistantContentType {
1354    Text(AssistantContent),
1355    ToolCall(OutputFunctionCall),
1356    Reasoning(OpenAIReasoning),
1357}
1358
1359/// System content for the OpenAI Responses API.
1360/// Uses `input_text` type to match the Responses API format.
1361#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1362#[serde(tag = "type", rename_all = "snake_case")]
1363pub enum SystemContent {
1364    InputText { text: String },
1365}
1366
1367impl From<String> for SystemContent {
1368    fn from(s: String) -> Self {
1369        SystemContent::InputText { text: s }
1370    }
1371}
1372
1373impl std::str::FromStr for SystemContent {
1374    type Err = std::convert::Infallible;
1375
1376    fn from_str(s: &str) -> Result<Self, Self::Err> {
1377        Ok(SystemContent::InputText {
1378            text: s.to_string(),
1379        })
1380    }
1381}
1382
1383/// Different types of user content.
1384#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1385#[serde(tag = "type", rename_all = "snake_case")]
1386pub enum UserContent {
1387    InputText {
1388        text: String,
1389    },
1390    InputImage {
1391        image_url: String,
1392        #[serde(default)]
1393        detail: ImageDetail,
1394    },
1395    InputFile {
1396        #[serde(skip_serializing_if = "Option::is_none")]
1397        file_url: Option<String>,
1398        #[serde(skip_serializing_if = "Option::is_none")]
1399        file_data: Option<String>,
1400        #[serde(skip_serializing_if = "Option::is_none")]
1401        filename: Option<String>,
1402    },
1403    Audio {
1404        input_audio: InputAudio,
1405    },
1406    #[serde(rename = "tool")]
1407    ToolResult {
1408        tool_call_id: String,
1409        output: String,
1410    },
1411}
1412
1413impl TryFrom<message::Message> for Vec<Message> {
1414    type Error = message::MessageError;
1415
1416    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1417        match message {
1418            message::Message::User { content } => {
1419                let (tool_results, other_content): (Vec<_>, Vec<_>) = content
1420                    .into_iter()
1421                    .partition(|content| matches!(content, message::UserContent::ToolResult(_)));
1422
1423                // If there are messages with both tool results and user content, openai will only
1424                //  handle tool results. It's unlikely that there will be both.
1425                if !tool_results.is_empty() {
1426                    tool_results
1427                        .into_iter()
1428                        .map(|content| match content {
1429                            message::UserContent::ToolResult(message::ToolResult {
1430                                call_id,
1431                                content,
1432                                ..
1433                            }) => Ok::<_, message::MessageError>(Message::ToolResult {
1434                                tool_call_id: call_id.ok_or_else(|| {
1435                                    MessageError::ConversionError(
1436                                        "Tool result `call_id` is required for OpenAI Responses API"
1437                                            .into(),
1438                                    )
1439                                })?,
1440                                output: {
1441                                    let res = content.first();
1442                                    match res {
1443                                        completion::message::ToolResultContent::Text(Text {
1444                                            text,
1445                                        }) => text,
1446                                        _ => return  Err(MessageError::ConversionError("This API only currently supports text tool results".into()))
1447                                    }
1448                                },
1449                            }),
1450                            _ => unreachable!(),
1451                        })
1452                        .collect::<Result<Vec<_>, _>>()
1453                } else {
1454                    let other_content = other_content
1455                        .into_iter()
1456                        .map(|content| match content {
1457                            message::UserContent::Text(message::Text { text }) => {
1458                                Ok(UserContent::InputText { text })
1459                            }
1460                            message::UserContent::Image(message::Image {
1461                                data,
1462                                detail,
1463                                media_type,
1464                                ..
1465                            }) => {
1466                                let url = match data {
1467                                    DocumentSourceKind::Base64(data) => {
1468                                        let media_type = if let Some(media_type) = media_type {
1469                                            media_type.to_mime_type().to_string()
1470                                        } else {
1471                                            String::new()
1472                                        };
1473                                        format!("data:{media_type};base64,{data}")
1474                                    }
1475                                    DocumentSourceKind::Url(url) => url,
1476                                    DocumentSourceKind::Raw(_) => {
1477                                        return Err(MessageError::ConversionError(
1478                                            "Raw files not supported, encode as base64 first"
1479                                                .into(),
1480                                        ));
1481                                    }
1482                                    doc => {
1483                                        return Err(MessageError::ConversionError(format!(
1484                                            "Unsupported document type: {doc}"
1485                                        )));
1486                                    }
1487                                };
1488
1489                                Ok(UserContent::InputImage {
1490                                    image_url: url,
1491                                    detail: detail.unwrap_or_default(),
1492                                })
1493                            }
1494                            message::UserContent::Document(message::Document {
1495                                media_type: Some(DocumentMediaType::PDF),
1496                                data,
1497                                ..
1498                            }) => {
1499                                let (file_data, file_url) = match data {
1500                                    DocumentSourceKind::Base64(data) => {
1501                                        (Some(format!("data:application/pdf;base64,{data}")), None)
1502                                    }
1503                                    DocumentSourceKind::Url(url) => (None, Some(url)),
1504                                    DocumentSourceKind::Raw(_) => {
1505                                        return Err(MessageError::ConversionError(
1506                                            "Raw files not supported, encode as base64 first"
1507                                                .into(),
1508                                        ));
1509                                    }
1510                                    doc => {
1511                                        return Err(MessageError::ConversionError(format!(
1512                                            "Unsupported document type: {doc}"
1513                                        )));
1514                                    }
1515                                };
1516
1517                                Ok(UserContent::InputFile {
1518                                    file_url,
1519                                    file_data,
1520                                    filename: Some("document.pdf".into()),
1521                                })
1522                            }
1523                            message::UserContent::Document(message::Document {
1524                                data: DocumentSourceKind::Base64(text),
1525                                ..
1526                            }) => Ok(UserContent::InputText { text }),
1527                            message::UserContent::Audio(message::Audio {
1528                                data: DocumentSourceKind::Base64(data),
1529                                media_type,
1530                                ..
1531                            }) => Ok(UserContent::Audio {
1532                                input_audio: InputAudio {
1533                                    data,
1534                                    format: match media_type {
1535                                        Some(media_type) => media_type,
1536                                        None => AudioMediaType::MP3,
1537                                    },
1538                                },
1539                            }),
1540                            message::UserContent::Audio(_) => Err(MessageError::ConversionError(
1541                                "Audio must be base64 encoded data".into(),
1542                            )),
1543                            _ => unreachable!(),
1544                        })
1545                        .collect::<Result<Vec<_>, _>>()?;
1546
1547                    let other_content = OneOrMany::many(other_content).map_err(|_| {
1548                        MessageError::ConversionError(
1549                            "User message did not contain OpenAI Responses-compatible content"
1550                                .to_string(),
1551                        )
1552                    })?;
1553
1554                    Ok(vec![Message::User {
1555                        content: other_content,
1556                        name: None,
1557                    }])
1558                }
1559            }
1560            message::Message::Assistant { content, id } => {
1561                let assistant_message_id = id.ok_or_else(|| {
1562                    MessageError::ConversionError(
1563                        "Assistant message ID is required for OpenAI Responses API".into(),
1564                    )
1565                })?;
1566
1567                match content.first() {
1568                    crate::message::AssistantContent::Text(Text { text }) => {
1569                        Ok(vec![Message::Assistant {
1570                            id: assistant_message_id.clone(),
1571                            status: ToolStatus::Completed,
1572                            content: OneOrMany::one(AssistantContentType::Text(
1573                                AssistantContent::OutputText(Text { text }),
1574                            )),
1575                            name: None,
1576                        }])
1577                    }
1578                    crate::message::AssistantContent::ToolCall(crate::message::ToolCall {
1579                        id,
1580                        call_id,
1581                        function,
1582                        ..
1583                    }) => Ok(vec![Message::Assistant {
1584                        content: OneOrMany::one(AssistantContentType::ToolCall(
1585                            OutputFunctionCall {
1586                                call_id: call_id.ok_or_else(|| {
1587                                    MessageError::ConversionError(
1588                                        "Tool call `call_id` is required for OpenAI Responses API"
1589                                            .into(),
1590                                    )
1591                                })?,
1592                                arguments: function.arguments,
1593                                id,
1594                                name: function.name,
1595                                status: ToolStatus::Completed,
1596                            },
1597                        )),
1598                        id: assistant_message_id.clone(),
1599                        name: None,
1600                        status: ToolStatus::Completed,
1601                    }]),
1602                    crate::message::AssistantContent::Reasoning(reasoning) => {
1603                        let openai_reasoning = openai_reasoning_from_core(&reasoning)?;
1604                        Ok(vec![Message::Assistant {
1605                            content: OneOrMany::one(AssistantContentType::Reasoning(
1606                                openai_reasoning,
1607                            )),
1608                            id: assistant_message_id,
1609                            name: None,
1610                            status: ToolStatus::Completed,
1611                        }])
1612                    }
1613                    crate::message::AssistantContent::Image(_) => {
1614                        Err(MessageError::ConversionError(
1615                            "Assistant image content is not supported in OpenAI Responses API"
1616                                .into(),
1617                        ))
1618                    }
1619                }
1620            }
1621        }
1622    }
1623}
1624
1625impl FromStr for UserContent {
1626    type Err = Infallible;
1627
1628    fn from_str(s: &str) -> Result<Self, Self::Err> {
1629        Ok(UserContent::InputText {
1630            text: s.to_string(),
1631        })
1632    }
1633}