Skip to main content

openai_interface/responses/create/
request.rs

1//! Request body and POST method for the Responses API (`POST /responses`).
2//!
3//! The request format follows the OpenAI Responses API as implemented by the
4//! official Python SDK, restricted to the parameters documented by OpenAI and
5//! the compatible providers (DeepSeek, Qwen). Provider-proprietary parameters
6//! are opt-in via the `deepseek` / `qwen` cargo features.
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11use url::Url;
12
13use crate::{
14    chat::{ServiceTier, create::request::ReasoningEffort},
15    errors::OapiError,
16    rest::post::{Post, PostNoStream, PostStream},
17};
18
19/// Creates a model response for the given input.
20///
21/// # Example
22///
23/// ```rust,no_run
24/// use openai_interface::responses::create::request::{Input, RequestBody};
25/// use openai_interface::rest::{default_client, post::PostNoStream};
26///
27/// const DEEPSEEK_URL: &'static str = "https://api.deepseek.com";
28/// const DEEPSEEK_MODEL: &'static str = "deepseek-v4-flash";
29///
30/// #[tokio::main]
31/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
32///     let request = RequestBody {
33///         model: DEEPSEEK_MODEL.to_string(),
34///         input: Input::Text("Hello!".to_string()),
35///         instructions: Some("You are a helpful assistant.".to_string()),
36///         ..Default::default()
37///     };
38///
39///     let response = request
40///         .get_response(&default_client(), DEEPSEEK_URL, "YOUR_API_KEY")
41///         .await?;
42///     println!("{}", response.output_text());
43///     Ok(())
44/// }
45/// ```
46#[derive(Serialize, Debug, Default, Clone)]
47pub struct RequestBody {
48    /// Whether to run the model response in the background, returning a
49    /// queued response immediately. Not supported by DeepSeek and Qwen
50    /// (synchronous calls only).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub background: Option<bool>,
53
54    /// The ID of an existing conversation to prepend to the input. Items
55    /// from this response are automatically added to the conversation after
56    /// it completes. Mutually exclusive with `previous_response_id`.
57    /// Not supported by DeepSeek (stateless API).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub conversation: Option<String>,
60
61    /// Additional output data to include in the model response, e.g.
62    /// `"message.output_text.logprobs"` or `"reasoning.encrypted_content"`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub include: Option<Vec<String>>,
65
66    /// Text, image, or file inputs to the model, used to generate a
67    /// response. Either a plain string (treated as one `user` message) or
68    /// a list of input items. Together with [`Self::instructions`], at
69    /// least one must be provided.
70    pub input: Input,
71
72    /// A system (or developer) message inserted into the model's context.
73    /// When used along with `previous_response_id`, the instructions from a
74    /// previous response are not carried over.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub instructions: Option<String>,
77
78    /// An upper bound for the number of tokens that can be generated for a
79    /// response, including visible output tokens and reasoning tokens.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub max_output_tokens: Option<u32>,
82
83    /// The maximum number of total calls to built-in tools that can be
84    /// processed in a response.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub max_tool_calls: Option<u32>,
87
88    /// Set of 16 key-value pairs that can be attached to an object. Keys
89    /// are strings with a maximum length of 64 characters; values are
90    /// strings with a maximum length of 512 characters. Not supported by
91    /// DeepSeek.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub metadata: Option<HashMap<String, String>>,
94
95    /// Model ID used to generate the response, e.g. `deepseek-v4-flash` or
96    /// `qwen3.8-max`.
97    pub model: String,
98
99    /// Whether to allow the model to run tool calls in parallel. Ignored by
100    /// DeepSeek (parallel tool calls are always enabled).
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub parallel_tool_calls: Option<bool>,
103
104    /// The unique ID of the previous response to the model. Use this to
105    /// create multi-turn conversations without resending the full input
106    /// history. Mutually exclusive with `conversation`. Not supported by
107    /// DeepSeek (stateless API); supported by Qwen.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub previous_response_id: Option<String>,
110
111    /// Used by OpenAI to cache responses for similar requests to optimize
112    /// cache hit rates. Replaces the `user` field. Not supported by
113    /// DeepSeek (caching is automatic there).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub prompt_cache_key: Option<String>,
116
117    /// Qwen: whether to enable thinking mode for hybrid-thinking models.
118    /// The thinking content is returned via `reasoning` output items.
119    /// Non-standard parameter; `reasoning.effort` is preferred.
120    #[cfg(feature = "qwen")]
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub enable_thinking: Option<bool>,
123
124    /// Configuration options for reasoning models.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub reasoning: Option<ReasoningConfig>,
127
128    /// A stable identifier used to help detect users of your application
129    /// that may be violating the usage policies.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub safety_identifier: Option<String>,
132
133    /// Specifies the processing type used for serving the request. Not
134    /// supported by DeepSeek.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub service_tier: Option<ServiceTier>,
137
138    /// Whether to store the generated model response for later retrieval
139    /// via the retrieve endpoint. DeepSeek always stores nothing (its
140    /// Responses API is stateless).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub store: Option<bool>,
143
144    /// If set to `true`, the response is streamed back as semantic
145    /// server-sent events (see [`super::response::ResponseStreamEvent`]).
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub stream: Option<bool>,
148
149    /// What sampling temperature to use, between 0 and 2. Higher values
150    /// make the output more random. Has no effect in thinking mode.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub temperature: Option<f32>,
153
154    /// Configuration options for a text response from the model: plain
155    /// text or structured JSON data.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub text: Option<TextConfig>,
158
159    /// How the model should select which tool (or tools) to use when
160    /// generating a response.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub tool_choice: Option<ToolChoice>,
163
164    /// An array of tools the model may call while generating a response.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub tools: Option<Vec<Tool>>,
167
168    /// An integer between 0 and 20 specifying the number of most likely
169    /// tokens to return at each token position, each with an associated
170    /// log probability.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub top_logprobs: Option<u32>,
173
174    /// An alternative to sampling with temperature, called nucleus
175    /// sampling. Has no effect in thinking mode.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub top_p: Option<f32>,
178
179    /// The truncation strategy to use for the model response. DeepSeek
180    /// only supports `disabled` (over-long inputs return an error).
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub truncation: Option<Truncation>,
183
184    /// A stable identifier for your end-users. Being replaced by
185    /// `safety_identifier` and `prompt_cache_key`.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub user: Option<String>,
188
189    /// Other request body fields that are not covered above. Useful for
190    /// provider-specific parameters such as Qwen's `ocr_options`.
191    #[serde(flatten, skip_serializing_if = "Option::is_none")]
192    pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
193}
194
195/// The `input` parameter: either a plain string (treated as a single `user`
196/// message) or a list of input items.
197#[derive(Serialize, Debug, Clone)]
198#[serde(untagged)]
199pub enum Input {
200    /// A plain-text input, treated as a single `user` message.
201    Text(String),
202    /// A list of input items describing the conversation so far.
203    Items(Vec<InputItem>),
204}
205
206impl Default for Input {
207    fn default() -> Self {
208        Self::Text(String::new())
209    }
210}
211
212impl From<&str> for Input {
213    fn from(value: &str) -> Self {
214        Self::Text(value.to_string())
215    }
216}
217
218impl From<String> for Input {
219    fn from(value: String) -> Self {
220        Self::Text(value)
221    }
222}
223
224impl From<Vec<InputItem>> for Input {
225    fn from(value: Vec<InputItem>) -> Self {
226        Self::Items(value)
227    }
228}
229
230/// An item of the input list.
231///
232/// Item types not listed here can be sent through
233/// [`Self::Other`] as raw JSON.
234#[derive(Serialize, Debug, Clone)]
235#[serde(tag = "type", rename_all = "snake_case")]
236pub enum InputItem {
237    /// A message with a role and content.
238    Message {
239        /// The role of the message author: `user`, `assistant`, `system`,
240        /// or `developer`.
241        role: Role,
242        /// The message content: plain text or an array of content parts.
243        content: MessageContent,
244        /// The unique ID of the message (present when passing back an
245        /// output message from a previous response).
246        #[serde(skip_serializing_if = "Option::is_none")]
247        id: Option<String>,
248        /// The status of the message (present when passing back an output
249        /// message from a previous response).
250        #[serde(skip_serializing_if = "Option::is_none")]
251        status: Option<String>,
252    },
253    /// A call to a user-defined function, e.g. returned by a previous
254    /// response. Must be paired with a matching
255    /// [`InputItem::FunctionCallOutput`].
256    FunctionCall {
257        /// The ID that pairs this call with its output.
258        call_id: String,
259        /// The name of the function to call.
260        #[serde(skip_serializing_if = "Option::is_none")]
261        name: Option<String>,
262        /// The arguments of the call, as a JSON string.
263        #[serde(skip_serializing_if = "Option::is_none")]
264        arguments: Option<String>,
265        /// The unique ID of the function call item.
266        #[serde(skip_serializing_if = "Option::is_none")]
267        id: Option<String>,
268    },
269    /// The output of a function call.
270    FunctionCallOutput {
271        /// The ID that pairs this output with the corresponding
272        /// [`InputItem::FunctionCall`].
273        call_id: String,
274        /// The result of the function call: plain text or an array of
275        /// content parts.
276        output: FunctionCallOutputContent,
277    },
278    /// A reasoning item from a previous response.
279    Reasoning {
280        /// The unique ID of the reasoning item.
281        #[serde(skip_serializing_if = "Option::is_none")]
282        id: Option<String>,
283        /// Plaintext reasoning content parts.
284        #[serde(skip_serializing_if = "Option::is_none")]
285        content: Option<Vec<ReasoningTextPart>>,
286        /// Reasoning summaries.
287        #[serde(skip_serializing_if = "Option::is_none")]
288        summary: Option<Vec<SummaryTextPart>>,
289    },
290    /// A web search call from a previous response, passed back as-is so
291    /// the server can restore the search results.
292    WebSearchCall {
293        /// The unique ID of the web search call.
294        #[serde(skip_serializing_if = "Option::is_none")]
295        id: Option<String>,
296        /// The search action performed by the server.
297        #[serde(skip_serializing_if = "Option::is_none")]
298        action: Option<serde_json::Value>,
299    },
300    /// A call to a custom tool (e.g. the `apply_patch` tool used by
301    /// Codex-style agents).
302    CustomToolCall {
303        /// The ID that pairs this call with its output.
304        call_id: String,
305        /// The name of the custom tool.
306        name: String,
307        /// The input of the tool call.
308        input: String,
309        /// The unique ID of the custom tool call item.
310        #[serde(skip_serializing_if = "Option::is_none")]
311        id: Option<String>,
312    },
313    /// The output of a custom tool call.
314    CustomToolCallOutput {
315        /// The ID that pairs this output with the corresponding
316        /// [`InputItem::CustomToolCall`].
317        call_id: String,
318        /// The result of the tool call: plain text or an array of content
319        /// parts.
320        output: FunctionCallOutputContent,
321    },
322    /// An input item of a type not covered by the variants above, sent
323    /// through as raw JSON.
324    #[serde(untagged)]
325    Other(serde_json::Value),
326}
327
328/// The role of a message author.
329#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
330#[serde(rename_all = "lowercase")]
331pub enum Role {
332    User,
333    Assistant,
334    System,
335    Developer,
336}
337
338/// The content of an input message: either plain text or an array of
339/// content parts.
340#[derive(Serialize, Debug, Clone)]
341#[serde(untagged)]
342pub enum MessageContent {
343    /// Plain-text message content.
344    Text(String),
345    /// An array of content parts (`input_text`, `input_image`, ...).
346    Parts(Vec<InputContentPart>),
347}
348
349impl From<&str> for MessageContent {
350    fn from(value: &str) -> Self {
351        Self::Text(value.to_string())
352    }
353}
354
355impl From<String> for MessageContent {
356    fn from(value: String) -> Self {
357        Self::Text(value)
358    }
359}
360
361impl From<Vec<InputContentPart>> for MessageContent {
362    fn from(value: Vec<InputContentPart>) -> Self {
363        Self::Parts(value)
364    }
365}
366
367/// The output content of a function (or custom tool) call: either plain
368/// text or an array of content parts.
369#[derive(Serialize, Debug, Clone)]
370#[serde(untagged)]
371pub enum FunctionCallOutputContent {
372    /// Plain-text output.
373    Text(String),
374    /// An array of content parts (`input_text`, `input_image`, ...).
375    Parts(Vec<InputContentPart>),
376}
377
378impl From<&str> for FunctionCallOutputContent {
379    fn from(value: &str) -> Self {
380        Self::Text(value.to_string())
381    }
382}
383
384impl From<String> for FunctionCallOutputContent {
385    fn from(value: String) -> Self {
386        Self::Text(value)
387    }
388}
389
390/// A content part of an input message or tool output.
391#[derive(Serialize, Debug, Clone)]
392#[serde(tag = "type", rename_all = "snake_case")]
393pub enum InputContentPart {
394    /// A text input part.
395    InputText {
396        /// The text content.
397        text: String,
398    },
399    /// An assistant text output part (only valid in `assistant` messages).
400    OutputText {
401        /// The text content.
402        text: String,
403    },
404    /// An image input part. `image_url` and `file_id` are mutually
405    /// exclusive; exactly one must be provided.
406    InputImage {
407        /// The image source: an `http(s)` URL, a base64 data URL
408        /// (`data:image/jpeg;base64,...`), or OpenAI's object form.
409        #[serde(skip_serializing_if = "Option::is_none")]
410        image_url: Option<ImageUrl>,
411        /// How the image should be processed.
412        #[serde(skip_serializing_if = "Option::is_none")]
413        detail: Option<ImageDetail>,
414        /// The ID of an uploaded image file. Ignored together with
415        /// `detail` by DeepSeek when set.
416        #[serde(skip_serializing_if = "Option::is_none")]
417        file_id: Option<String>,
418    },
419    /// Qwen: a file input part (PDF or image), referenced by a public URL.
420    /// Only the `qwen3.5-ocr` model supports this part type.
421    #[cfg(feature = "qwen")]
422    InputFile {
423        /// The public URL of the file.
424        file_url: String,
425    },
426}
427
428/// The source of an input image. DeepSeek and Qwen take a plain URL
429/// string; OpenAI takes an object with a `url` field.
430#[derive(Serialize, Debug, Clone)]
431#[serde(untagged)]
432pub enum ImageUrl {
433    /// A plain URL string (DeepSeek / Qwen form).
434    Url(String),
435    /// The OpenAI object form.
436    Object {
437        /// The URL of the image.
438        url: String,
439    },
440}
441
442/// Controls how an input image is processed. `low` downscales the image
443/// before inference (faster, fewer tokens); the other values keep the
444/// original image.
445#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
446#[serde(rename_all = "lowercase")]
447pub enum ImageDetail {
448    Low,
449    High,
450    Auto,
451    Original,
452}
453
454/// A plaintext reasoning content part.
455#[derive(Serialize, Debug, Clone)]
456#[serde(tag = "type", rename = "reasoning_text")]
457pub struct ReasoningTextPart {
458    /// The chain-of-thought text.
459    pub text: String,
460}
461
462/// A reasoning summary content part.
463#[derive(Serialize, Debug, Clone)]
464#[serde(tag = "type", rename = "summary_text")]
465pub struct SummaryTextPart {
466    /// The summary text.
467    pub text: String,
468}
469
470/// Configuration options for reasoning models.
471#[derive(Serialize, Debug, Clone, Default)]
472pub struct ReasoningConfig {
473    /// Constrains the effort on reasoning. Reducing the effort can result
474    /// in faster responses and fewer reasoning tokens. Providers accept
475    /// subsets of the values and map unsupported values to their nearest
476    /// effort level; DeepSeek maps `none` to thinking mode off.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub effort: Option<ReasoningEffort>,
479
480    /// The verbosity of reasoning summaries generated by the model.
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub summary: Option<SummaryEffort>,
483}
484
485/// The verbosity of reasoning summaries.
486#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
487#[serde(rename_all = "lowercase")]
488pub enum SummaryEffort {
489    Concise,
490    Detailed,
491    Auto,
492}
493
494/// Configuration options for a text response from the model.
495#[derive(Serialize, Debug, Clone, Default)]
496pub struct TextConfig {
497    /// The output format: plain text, JSON mode, or Structured Outputs
498    /// with a JSON schema.
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub format: Option<TextFormat>,
501}
502
503/// The format of the text output.
504#[derive(Serialize, Debug, Clone)]
505#[serde(tag = "type", rename_all = "snake_case")]
506pub enum TextFormat {
507    /// Plain-text output (the default).
508    Text,
509    /// JSON mode: the model output is guaranteed to be valid JSON.
510    JsonObject,
511    /// Structured Outputs: the model output matches the supplied JSON
512    /// schema.
513    JsonSchema {
514        /// The name of the schema.
515        name: String,
516        /// A description of what the schema is for.
517        #[serde(skip_serializing_if = "Option::is_none")]
518        description: Option<String>,
519        /// The JSON schema the output must conform to.
520        schema: serde_json::Value,
521        /// Whether to enable strict schema adherence.
522        #[serde(skip_serializing_if = "Option::is_none")]
523        strict: Option<bool>,
524    },
525}
526
527/// How the model should select which tool (or tools) to use.
528#[derive(Serialize, Debug, Clone)]
529#[serde(untagged)]
530pub enum ToolChoice {
531    /// A mode string: `none`, `auto`, or `required`.
532    Mode(ToolChoiceMode),
533    /// A specific tool to force the model to call.
534    Specific(ToolChoiceSpecific),
535}
536
537/// The tool choice mode.
538#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
539#[serde(rename_all = "lowercase")]
540pub enum ToolChoiceMode {
541    /// The model will not call any tool and instead generates a message.
542    None,
543    /// The model can pick between generating a message or calling one or
544    /// more tools (the default).
545    Auto,
546    /// The model must call one or more tools.
547    Required,
548}
549
550/// A specific tool the model is forced to call.
551#[derive(Serialize, Debug, Clone)]
552#[serde(tag = "type", rename_all = "snake_case")]
553pub enum ToolChoiceSpecific {
554    /// Force a specific function call by name.
555    Function {
556        /// The name of the function to call.
557        name: String,
558    },
559    /// Force the built-in web search tool. The `tools` list must then
560    /// include a `web_search` tool.
561    WebSearch,
562    /// Force a custom tool by name.
563    Custom {
564        /// The name of the custom tool.
565        name: String,
566    },
567}
568
569/// A tool the model may call while generating a response.
570#[derive(Serialize, Debug, Clone)]
571#[serde(tag = "type", rename_all = "snake_case")]
572pub enum Tool {
573    /// A user-defined function.
574    Function {
575        /// The name of the function. Must be non-empty, at most 128
576        /// characters, and match `^[a-zA-Z0-9_-]+$`; names must be unique.
577        name: String,
578        /// A description of what the function does.
579        #[serde(skip_serializing_if = "Option::is_none")]
580        description: Option<String>,
581        /// The function parameters, described as a JSON Schema object.
582        /// Omitting this defines a function with no parameters.
583        #[serde(skip_serializing_if = "Option::is_none")]
584        parameters: Option<serde_json::Value>,
585        /// Whether to enable strict schema adherence for the parameters.
586        #[serde(skip_serializing_if = "Option::is_none")]
587        strict: Option<bool>,
588    },
589    /// A server-side web search tool.
590    WebSearch,
591    /// Qwen: a web page extraction tool. Must be used together with
592    /// `web_search`.
593    #[cfg(feature = "qwen")]
594    WebExtractor,
595    /// Qwen: a code interpreter tool that executes code and returns the
596    /// result. Some models require thinking mode to be enabled.
597    #[cfg(feature = "qwen")]
598    CodeInterpreter,
599    /// Qwen: a text-to-image search tool.
600    #[cfg(feature = "qwen")]
601    WebSearchImage,
602    /// Qwen: an image-to-image search tool; the input must contain an
603    /// image URL.
604    #[cfg(feature = "qwen")]
605    ImageSearch,
606    /// Qwen: a knowledge-base search tool over one uploaded vector store.
607    #[cfg(feature = "qwen")]
608    FileSearch {
609        /// The knowledge-base (vector store) IDs to search. Currently only
610        /// one ID is supported.
611        vector_store_ids: Vec<String>,
612    },
613    /// Qwen: a Model Context Protocol (MCP) tool.
614    #[cfg(feature = "qwen")]
615    Mcp {
616        /// The protocol used to talk to the MCP server, e.g. `sse`.
617        server_protocol: String,
618        /// A label identifying the MCP server.
619        server_label: String,
620        /// The endpoint URL of the MCP server.
621        server_url: String,
622        /// A description of the MCP server for the model.
623        #[serde(skip_serializing_if = "Option::is_none")]
624        server_description: Option<String>,
625        /// Request headers, e.g. `Authorization`.
626        #[serde(skip_serializing_if = "Option::is_none")]
627        headers: Option<HashMap<String, String>>,
628    },
629}
630
631/// The truncation strategy to use when the input exceeds the model's
632/// context window.
633#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
634#[serde(rename_all = "lowercase")]
635pub enum Truncation {
636    /// Truncate items from the beginning of the conversation to fit the
637    /// context window.
638    Auto,
639    /// Fail the request with a 400 error instead (the default; the only
640    /// behavior supported by DeepSeek).
641    Disabled,
642}
643
644impl RequestBody {
645    /// Whether this request asks for a streamed response. Defaults to
646    /// `false` when [`RequestBody::stream`] is `None`.
647    pub fn is_streaming(&self) -> bool {
648        self.stream.unwrap_or(false)
649    }
650}
651
652impl Post for RequestBody {
653    fn is_streaming(&self) -> bool {
654        RequestBody::is_streaming(self)
655    }
656
657    /// Builds the URL for the request.
658    ///
659    /// `base_url` should be like <https://api.openai.com/v1>
660    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
661        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
662        url.path_segments_mut()
663            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
664            .push("responses");
665        Ok(url.to_string())
666    }
667}
668
669impl PostNoStream for RequestBody {
670    type Response = crate::responses::Response;
671}
672
673impl PostStream for RequestBody {
674    type Response = super::response::ResponseStreamEvent;
675}
676
677#[cfg(test)]
678mod tests {
679    //! Offline serialization tests pinning the wire format of the request
680    //! body. No network access is involved.
681
682    use super::*;
683
684    fn base_request() -> RequestBody {
685        RequestBody {
686            model: "test-model".to_string(),
687            input: Input::Text("你好".to_string()),
688            ..Default::default()
689        }
690    }
691
692    #[test]
693    fn serializes_minimal_request() {
694        let value = serde_json::to_value(base_request()).unwrap();
695        assert_eq!(
696            value,
697            serde_json::json!({
698                "model": "test-model",
699                "input": "你好",
700            })
701        );
702    }
703
704    #[test]
705    fn serializes_input_items() {
706        let request = RequestBody {
707            input: Input::Items(vec![
708                InputItem::Message {
709                    role: Role::System,
710                    content: MessageContent::Text("You are helpful.".into()),
711                    id: None,
712                    status: None,
713                },
714                InputItem::Message {
715                    role: Role::User,
716                    content: MessageContent::Parts(vec![InputContentPart::InputText {
717                        text: "hi".into(),
718                    }]),
719                    id: None,
720                    status: None,
721                },
722                InputItem::FunctionCall {
723                    call_id: "fc_1".into(),
724                    name: Some("get_weather".into()),
725                    arguments: Some("{\"city\": \"北京\"}".into()),
726                    id: None,
727                },
728                InputItem::FunctionCallOutput {
729                    call_id: "fc_1".into(),
730                    output: FunctionCallOutputContent::Text("sunny".into()),
731                },
732            ]),
733            ..base_request()
734        };
735
736        let value = serde_json::to_value(request).unwrap();
737        assert_eq!(
738            value["input"],
739            serde_json::json!([
740                {"type": "message", "role": "system", "content": "You are helpful."},
741                {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
742                {"type": "function_call", "call_id": "fc_1", "name": "get_weather", "arguments": "{\"city\": \"北京\"}"},
743                {"type": "function_call_output", "call_id": "fc_1", "output": "sunny"},
744            ])
745        );
746    }
747
748    #[test]
749    fn serializes_tools_and_tool_choice() {
750        let request = RequestBody {
751            tools: Some(vec![Tool::Function {
752                name: "get_weather".into(),
753                description: Some("获取天气".into()),
754                parameters: Some(serde_json::json!({
755                    "type": "object",
756                    "properties": {"city": {"type": "string"}},
757                    "required": ["city"],
758                })),
759                strict: None,
760            }]),
761            tool_choice: Some(ToolChoice::Specific(ToolChoiceSpecific::Function {
762                name: "get_weather".into(),
763            })),
764            reasoning: Some(ReasoningConfig {
765                effort: Some(ReasoningEffort::Low),
766                summary: None,
767            }),
768            text: Some(TextConfig {
769                format: Some(TextFormat::JsonObject),
770            }),
771            ..base_request()
772        };
773
774        let value = serde_json::to_value(request).unwrap();
775        assert_eq!(
776            value["tool_choice"],
777            serde_json::json!({"type": "function", "name": "get_weather"})
778        );
779        assert_eq!(value["reasoning"], serde_json::json!({"effort": "low"}));
780        assert_eq!(
781            value["text"],
782            serde_json::json!({"format": {"type": "json_object"}})
783        );
784        assert_eq!(value["tools"][0]["type"], "function");
785        assert_eq!(value["tools"][0]["name"], "get_weather");
786    }
787
788    #[test]
789    fn extra_body_flattens_into_top_level() {
790        let mut request = base_request();
791        request.extra_body_map = Some(
792            serde_json::json!({"ocr_options": {}})
793                .as_object()
794                .unwrap()
795                .clone(),
796        );
797        let value = serde_json::to_value(request).unwrap();
798        assert_eq!(value["ocr_options"], serde_json::json!({}));
799    }
800}