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}