Skip to main content

openai_interface/chat/create/
request.rs

1//! This module contains the request body and POST method for the chat completion API.
2
3use std::collections::HashMap;
4
5use serde::Serialize;
6use url::Url;
7
8use crate::{
9    chat::ServiceTier,
10    errors::OapiError,
11    rest::post::{Post, PostNoStream, PostStream},
12};
13
14/// Creates a model response for the given chat conversation.
15///
16/// # Example
17///
18/// ```rust,no_run
19/// use futures_util::StreamExt;
20/// use openai_interface::chat::create::request::{Message, RequestBody};
21/// use openai_interface::rest::{default_client, post::PostStream};
22///
23/// const DEEPSEEK_CHAT_URL: &'static str = "https://api.deepseek.com";
24/// const DEEPSEEK_MODEL: &'static str = "deepseek-v4-flash";
25///
26/// #[tokio::main]
27/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
28///     // Needs the `ferritls` cargo feature; drop this line if you install
29///     // your own rustls crypto provider (see `openai_interface::rest`).
30///     # #[cfg(feature = "ferritls")]
31///     openai_interface::rest::install_crypto_provider().ok();
32///
33///     let request = RequestBody {
34///         messages: vec![
35///             Message::System {
36///                 content: "This is a request of test purpose. Reply briefly".to_string(),
37///                 name: None,
38///             },
39///             Message::User {
40///                 content: "What's your name?".into(),
41///                 name: None,
42///             },
43///         ],
44///         model: DEEPSEEK_MODEL.to_string(),
45///         stream: Some(true),
46///         ..Default::default()
47///     };
48///
49///     let mut response = request
50///         .get_stream_response_string(&default_client(), DEEPSEEK_CHAT_URL, "YOUR_API_KEY")
51///         .await?;
52///
53///     while let Some(chunk) = response.next().await {
54///         println!("{}", chunk?);
55///     }
56///     Ok(())
57/// }
58/// ```
59#[derive(Serialize, Debug, Default, Clone)]
60pub struct RequestBody {
61    /// Parameters for audio output. Required when audio output is requested
62    /// with `modalities: ["audio"]`.
63    /// [Learn more](https://platform.openai.com/docs/guides/audio).
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub audio: Option<ChatCompletionAudioParam>,
66
67    /// Number between -2.0 and 2.0. Positive values penalize new tokens based on their
68    /// existing frequency in the text so far, decreasing the model's likelihood to
69    /// repeat the same line verbatim.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub frequency_penalty: Option<f32>,
72
73    /// Whether to return log probabilities of the output tokens or not. If true,
74    /// returns the log probabilities of each output token returned in the `content` of
75    /// `message`.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub logprobs: Option<bool>,
78
79    /// An upper bound for the number of tokens that can be generated for a completion,
80    /// including visible output tokens and reasoning tokens.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub max_completion_tokens: Option<u32>,
83
84    /// The maximum number of tokens that can be generated in the chat completion.
85    /// Deprecated according to OpenAI's Python SDK in favour of
86    /// `max_completion_tokens`.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub max_tokens: Option<u32>,
89
90    /// A list of messages comprising the conversation so far.
91    pub messages: Vec<Message>,
92
93    /// Modify the likelihood of specified tokens appearing in the completion.
94    ///
95    /// Accepts a JSON object that maps tokens (specified by their token ID in
96    /// the tokenizer) to an associated bias value from -100 to 100.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub logit_bias: Option<HashMap<u32, i32>>,
99
100    /// Configuration for running moderation on the request input and
101    /// generated output.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub moderation: Option<ChatModerationParam>,
104
105    /// Set of 16 key-value pairs that can be attached to an object. This can be useful
106    /// for storing additional information about the object in a structured format, and
107    /// querying for objects via API or the dashboard.
108    ///
109    /// Keys are strings with a maximum length of 64 characters. Values are strings with
110    /// a maximum length of 512 characters.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub metadata: Option<HashMap<String, String>>,
113
114    /// Output types that you would like the model to generate. Most models are capable
115    /// of generating text, which is the default:
116    ///
117    /// `["text"]`
118    ///
119    /// The `gpt-4o-audio-preview` model can also be used to
120    /// [generate audio](https://platform.openai.com/docs/guides/audio). To request that
121    /// this model generate both text and audio responses, you can use:
122    ///
123    /// `["text", "audio"]`
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub modalities: Option<Vec<Modality>>,
126
127    /// Name of the model to use to generate the response.
128    pub model: String, // The type of this attribute needs improvements.
129
130    /// How many chat completion choices to generate for each input message. Note that
131    /// you will be charged based on the number of generated tokens across all of the
132    /// choices. Keep `n` as `1` to minimize costs.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub n: Option<u32>,
135
136    /// Whether to enable
137    /// [parallel function calling](https://platform.openai.com/docs/guides/function-calling#configuring-parallel-function-calling)
138    /// during tool use.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub parallel_tool_calls: Option<bool>,
141
142    /// Static predicted output content, such as the content of a text file that is
143    /// being regenerated.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub prediction: Option<ChatCompletionPredictionContentParam>,
146
147    /// Number between -2.0 and 2.0. Positive values penalize new tokens based on
148    /// whether they appear in the text so far, increasing the model's likelihood to
149    /// talk about new topics.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub presence_penalty: Option<f32>,
152
153    /// Used by OpenAI to cache responses for similar requests to optimize your cache
154    /// hit rates. Replaces the `user` field.
155    /// [Learn more](https://platform.openai.com/docs/guides/prompt-caching).
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub prompt_cache_key: Option<String>,
158
159    /// Options for prompt caching. Supported for `gpt-5.6` and later models.
160    /// By default, OpenAI automatically chooses one implicit cache breakpoint;
161    /// set `mode` to `explicit` to disable the implicit breakpoint.
162    /// [Learn more](https://platform.openai.com/docs/guides/prompt-caching).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub prompt_cache_options: Option<PromptCacheOptions>,
165
166    /// Constrains effort on reasoning for
167    /// [reasoning models](https://platform.openai.com/docs/guides/reasoning).
168    /// Currently supported values are `none`, `minimal`, `low`, `medium`,
169    /// `high`, `xhigh`, and `max` (model-dependent). Reducing reasoning
170    /// effort can result in faster responses and fewer tokens used on
171    /// reasoning in a response. Defaults are provider- and model-dependent:
172    /// e.g. `medium` for GPT-5.5. Providers map unsupported values to the
173    /// nearest effort level.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub reasoning_effort: Option<ReasoningEffort>,
176
177    /// specifying the format that the model must output.
178    ///
179    /// Setting to `{ "type": "json_schema", "json_schema": {...} }` enables Structured
180    /// Outputs which ensures the model will match your supplied JSON schema. Learn more
181    /// in the
182    /// [Structured Outputs guide](https://platform.openai.com/docs/guides/structured-outputs).
183    /// Setting to `{ "type": "json_object" }` enables the older JSON mode, which
184    /// ensures the message the model generates is valid JSON. Using `json_schema` is
185    /// preferred for models that support it.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub response_format: Option<ResponseFormat>,
188
189    /// A stable identifier used to help detect users of your application that may be
190    /// violating OpenAI's usage policies. The IDs should be a string that uniquely
191    /// identifies each user. It is recommended to hash their username or email address, in
192    /// order to avoid sending any identifying information.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub safety_identifier: Option<String>,
195
196    /// If specified, the system will make a best effort to sample deterministically. Determinism
197    /// is not guaranteed, and you should refer to the `system_fingerprint` response parameter to
198    /// monitor changes in the backend.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub seed: Option<i64>,
201
202    /// Specifies the processing type used for serving the request.
203    ///
204    /// - If set to 'auto', then the request will be processed with the service tier
205    ///   configured in the Project settings. Unless otherwise configured, the Project
206    ///   will use 'default'.
207    /// - If set to 'default', then the request will be processed with the standard
208    ///   pricing and performance for the selected model.
209    /// - If set to '[flex](https://platform.openai.com/docs/guides/flex-processing)' or
210    ///   '[priority](https://openai.com/api-priority-processing/)', then the request
211    ///   will be processed with the corresponding service tier.
212    /// - When not set, the default behavior is 'auto'.
213    ///
214    /// When the `service_tier` parameter is set, the response body will include the
215    /// `service_tier` value based on the processing mode actually used to serve the
216    /// request. This response value may be different from the value set in the
217    /// parameter.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub service_tier: Option<ServiceTier>,
220
221    /// Up to 4 sequences where the API will stop generating further tokens. The
222    /// returned text will not contain the stop sequence.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub stop: Option<StopKeywords>,
225
226    /// Whether or not to store the output of this chat completion request for use in
227    /// our [model distillation](https://platform.openai.com/docs/guides/distillation)
228    /// or [evals](https://platform.openai.com/docs/guides/evals) products.
229    ///
230    /// Supports text and image inputs. Note: image inputs over 8MB will be dropped.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub store: Option<bool>,
233
234    /// Whether to stream back partial progress. If set to `true` (or left as
235    /// `Some(true)`), tokens will be sent as data-only
236    /// [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
237    /// as they become available, with the stream terminated by a `data: [DONE]`
238    /// message.
239    ///
240    /// Although it is optional, you should explicitly designate it
241    /// for an expected response.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub stream: Option<bool>,
244
245    /// Options for streaming response. Only set this when you set `stream: true`
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub stream_options: Option<StreamOptions>,
248
249    /// What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
250    /// make the output more random, while lower values like 0.2 will make it more
251    /// focused and deterministic. It is generally recommended to alter this or `top_p` but
252    /// not both.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub temperature: Option<f32>,
255
256    /// An alternative to sampling with temperature, called nucleus sampling, where the
257    /// model considers the results of the tokens with top_p probability mass. So 0.1
258    /// means only the tokens comprising the top 10% probability mass are considered.
259    ///
260    /// It is generally recommended to alter this or `temperature` but not both.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub top_p: Option<f32>,
263
264    /// Controls which (if any) tool is called by the model. `none` means the model will
265    /// not call any tool and instead generates a message. `auto` means the model can
266    /// pick between generating a message or calling one or more tools. `required` means
267    /// the model must call one or more tools. Specifying a particular tool via
268    /// `{"type": "function", "function": {"name": "my_function"}}` forces the model to
269    /// call that tool.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub tool_choice: Option<ToolChoice>,
272
273    /// A list of tools the model may call.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub tools: Option<Vec<RequestTool>>,
276
277    /// An integer between 0 and 20 specifying the number of most likely tokens to
278    /// return at each token position, each with an associated log probability.
279    /// `logprobs` must be set to `true` if this parameter is used.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub top_logprobs: Option<u32>,
282
283    /// DeepSeek: controls the switch between thinking and non-thinking mode.
284    /// Defaults to `enabled`. See
285    /// [the DeepSeek API reference](https://api-docs.deepseek.com/api/create-chat-completion).
286    #[cfg(feature = "deepseek")]
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub thinking: Option<DeepSeekThinking>,
289
290    /// DeepSeek: a custom user ID. Allowed character set is `[a-zA-Z0-9\-_]`
291    /// with a maximum length of 512. Do not include user privacy information.
292    /// It can be used to distinguish user identities for content safety
293    /// review, isolate KVCache, and schedule users.
294    #[cfg(feature = "deepseek")]
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub user_id: Option<String>,
297
298    /// Qwen: whether to enable thinking mode for hybrid-thinking models such
299    /// as Qwen3. When set to `true`, the thinking content is returned in the
300    /// `reasoning_content` field.
301    #[cfg(feature = "qwen")]
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub enable_thinking: Option<bool>,
304    /// Qwen: the maximum number of tokens available for the model's thinking
305    /// (chain-of-thought) process.
306    #[cfg(feature = "qwen")]
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub thinking_budget: Option<u32>,
309    /// Qwen: the size of the candidate set for sampling during generation.
310    /// Set to `null` or a value greater than 100 to disable `top_k` sampling.
311    #[cfg(feature = "qwen")]
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub top_k: Option<u32>,
314
315    /// This field is being replaced by `safety_identifier` and `prompt_cache_key`. Use
316    /// `prompt_cache_key` instead to maintain caching optimizations. A stable
317    /// identifier for your end-users. Used to boost cache hit rates by better bucketing
318    /// similar requests and to help OpenAI detect and prevent abuse.
319    /// [Learn more](https://platform.openai.com/docs/guides/safety-best-practices#safety-identifiers).
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub user: Option<String>,
322
323    /// Constrains the verbosity of the model's response. Lower values will result in
324    /// more concise responses, while higher values will result in more verbose
325    /// responses. Currently supported values are `low`, `medium`, and `high`.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub verbosity: Option<LowMediumHighEnum>,
328
329    /// This tool searches the web for relevant results to use in a response. Learn more
330    /// about the
331    /// [web search tool](https://platform.openai.com/docs/guides/tools-web-search?api-mode=chat).
332    #[serde(rename = "web_search_options", skip_serializing_if = "Option::is_none")]
333    pub web_search_options: Option<WebSearchOptions>,
334
335    /// Other request bodies that are not in standard OpenAI API and
336    /// not covered by the fields above.
337    #[serde(flatten, skip_serializing_if = "Option::is_none")]
338    pub extra_body_map: Option<serde_json::Map<String, serde_json::Value>>,
339}
340
341#[derive(Serialize, Debug, Clone)]
342#[serde(tag = "role", rename_all = "lowercase")]
343pub enum Message {
344    /// In this case, the role of the message author is `system`.
345    /// The field `{ role = "system" }` is added automatically.
346    System {
347        /// The contents of the system message.
348        content: String,
349        /// An optional name for the participant.
350        ///
351        /// Provides the model information to differentiate between
352        /// participants of the same role.
353        #[serde(skip_serializing_if = "Option::is_none")]
354        name: Option<String>,
355    },
356    /// In this case, the role of the message author is `user`.
357    /// The field `{ role = "user" }` is added automatically.
358    User {
359        /// The contents of the user message: plain text, or an array of
360        /// multimodal content parts (`text`, `image_url`, `input_audio`,
361        /// `file`).
362        content: MessageContent,
363        /// An optional name for the participant.
364        ///
365        /// Provides the model information to differentiate between
366        /// participants of the same role.
367        #[serde(skip_serializing_if = "Option::is_none")]
368        name: Option<String>,
369    },
370    /// In this case, the role of the message author is `assistant`.
371    /// The field `{ role = "assistant" }` is added automatically.
372    Assistant {
373        /// The contents of the assistant message. Required unless `tool_calls`
374        /// or `function_call` is specified. (Note that `function_call` is deprecated
375        /// in favour of `tool_calls`.)
376        content: Option<String>,
377        /// Data about a previous audio response from the model. Required for
378        /// multi-turn audio conversations.
379        #[serde(skip_serializing_if = "Option::is_none")]
380        audio: Option<AssistantAudio>,
381        /// The refusal message by the assistant.
382        #[serde(skip_serializing_if = "Option::is_none")]
383        refusal: Option<String>,
384        #[serde(skip_serializing_if = "Option::is_none")]
385        name: Option<String>,
386        /// DeepSeek (Beta): set this to `true` to force the model to start its
387        /// answer by the content of the supplied prefix in this assistant
388        /// message. Requires `base_url = "https://api.deepseek.com/beta"`.
389        #[cfg(feature = "deepseek")]
390        #[serde(skip_serializing_if = "is_false")]
391        prefix: bool,
392        /// DeepSeek (Beta): used for the thinking mode in the
393        /// [Chat Prefix Completion](https://api-docs.deepseek.com/guides/chat_prefix_completion)
394        /// feature as the input for the CoT in the last assistant message.
395        /// When using this feature, `prefix` must be set to `true`.
396        #[cfg(feature = "deepseek")]
397        #[serde(skip_serializing_if = "Option::is_none")]
398        reasoning_content: Option<String>,
399
400        /// The tool calls generated by the model, such as function calls.
401        #[serde(skip_serializing_if = "Option::is_none")]
402        tool_calls: Option<Vec<AssistantToolCall>>,
403    },
404    /// In this case, the role of the message author is `assistant`.
405    /// The field `{ role = "tool" }` is added automatically.
406    Tool {
407        /// The contents of the tool message.
408        content: String,
409        /// Tool call that this message is responding to.
410        tool_call_id: String,
411    },
412    /// In this case, the role of the message author is `function`.
413    /// The field `{ role = "function" }` is added automatically.
414    Function {
415        /// The contents of the function message.
416        content: String,
417        /// The name of the function to call.
418        name: String,
419    },
420    /// In this case, the role of the message author is `developer`.
421    /// The field `{ role = "developer" }` is added automatically.
422    Developer {
423        /// The contents of the developer message.
424        content: String,
425        /// An optional name for the participant.
426        ///
427        /// Provides the model information to differentiate between
428        /// participants of the same role.
429        name: Option<String>,
430    },
431}
432
433/// The contents of a user message: either plain text, or an array of
434/// multimodal content parts.
435#[derive(Debug, Serialize, Clone)]
436#[serde(untagged)]
437pub enum MessageContent {
438    /// A plain-text message content.
439    Text(String),
440    /// An array of multimodal content parts (`text`, `image_url`,
441    /// `input_audio`, `file`).
442    Parts(Vec<ContentPart>),
443}
444
445impl From<&str> for MessageContent {
446    fn from(value: &str) -> Self {
447        Self::Text(value.to_string())
448    }
449}
450
451impl From<String> for MessageContent {
452    fn from(value: String) -> Self {
453        Self::Text(value)
454    }
455}
456
457impl From<Vec<ContentPart>> for MessageContent {
458    fn from(value: Vec<ContentPart>) -> Self {
459        Self::Parts(value)
460    }
461}
462
463impl Default for MessageContent {
464    fn default() -> Self {
465        Self::Text(String::new())
466    }
467}
468
469/// A content part of a multimodal user message.
470#[derive(Debug, Serialize, Clone)]
471#[serde(tag = "type", rename_all = "snake_case")]
472pub enum ContentPart {
473    /// Learn about [text inputs](https://platform.openai.com/docs/guides/text).
474    Text {
475        /// The text content.
476        text: String,
477        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
478        /// from the request's `prompt_cache_options.ttl`.
479        #[serde(skip_serializing_if = "Option::is_none")]
480        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
481    },
482    /// Learn about [image inputs](https://platform.openai.com/docs/guides/vision).
483    ImageUrl {
484        /// Contains either an image URL or a data URL for a base64 encoded image.
485        image_url: ContentPartImageUrl,
486        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
487        /// from the request's `prompt_cache_options.ttl`.
488        #[serde(skip_serializing_if = "Option::is_none")]
489        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
490    },
491    /// Learn about [audio inputs](https://platform.openai.com/docs/guides/audio).
492    InputAudio {
493        /// The audio input data and its format.
494        input_audio: ContentPartInputAudio,
495        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
496        /// from the request's `prompt_cache_options.ttl`.
497        #[serde(skip_serializing_if = "Option::is_none")]
498        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
499    },
500    /// Learn about [file inputs](https://platform.openai.com/docs/guides/text).
501    File {
502        /// The file input: base64 data, an uploaded file ID, or both with a
503        /// filename.
504        file: ContentPartFile,
505        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
506        /// from the request's `prompt_cache_options.ttl`.
507        #[serde(skip_serializing_if = "Option::is_none")]
508        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
509    },
510}
511
512/// Marks the exact end of a reusable prompt prefix.
513#[derive(Debug, Serialize, Clone)]
514pub struct PromptCacheBreakpoint {
515    /// The breakpoint mode. Always `explicit`.
516    pub mode: PromptCacheBreakpointMode,
517}
518
519/// The breakpoint mode. Always `explicit`.
520#[derive(Debug, Serialize, Clone)]
521#[serde(rename_all = "lowercase")]
522pub enum PromptCacheBreakpointMode {
523    Explicit,
524}
525
526/// Contains either an image URL or a data URL for a base64 encoded image.
527#[derive(Debug, Serialize, Clone)]
528pub struct ContentPartImageUrl {
529    /// Either a URL of the image or the base64 encoded image data.
530    pub url: String,
531    /// Specifies the detail level of the image.
532    /// [Learn more](https://platform.openai.com/docs/guides/vision#low-or-high-fidelity-image-understanding).
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub detail: Option<ImageDetail>,
535}
536
537/// The detail level of an image input.
538#[derive(Debug, Serialize, Clone, Copy)]
539#[serde(rename_all = "lowercase")]
540pub enum ImageDetail {
541    Auto,
542    Low,
543    High,
544}
545
546/// Base64 encoded audio input data.
547#[derive(Debug, Serialize, Clone)]
548pub struct ContentPartInputAudio {
549    /// Base64 encoded audio data.
550    pub data: String,
551    /// The format of the encoded audio data. Currently supports `wav` and
552    /// `mp3`.
553    pub format: InputAudioFormat,
554}
555
556/// The format of the encoded audio data.
557#[derive(Debug, Serialize, Clone, Copy)]
558#[serde(rename_all = "lowercase")]
559pub enum InputAudioFormat {
560    Wav,
561    Mp3,
562}
563
564/// A file input for a content part. At least one of `file_data` and
565/// `file_id` should be provided.
566#[derive(Debug, Serialize, Clone, Default)]
567pub struct ContentPartFile {
568    /// The base64 encoded file data, used when passing the file to the model
569    /// as a string.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub file_data: Option<String>,
572    /// The ID of an uploaded file to use as input.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub file_id: Option<String>,
575    /// The name of the file, used when passing the file to the model as a
576    /// string.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub filename: Option<String>,
579}
580
581/// Configuration for running moderation on the request input and generated
582/// output.
583#[derive(Debug, Serialize, Clone)]
584pub struct ChatModerationParam {
585    /// The moderation model to use for moderated completions, e.g.
586    /// `omni-moderation-latest`.
587    pub model: String,
588    /// The policy to apply to moderated response input and output.
589    #[serde(skip_serializing_if = "Option::is_none")]
590    pub policy: Option<ModerationPolicyParam>,
591}
592
593/// The policy to apply to moderated response input and output.
594#[derive(Debug, Serialize, Clone, Default)]
595pub struct ModerationPolicyParam {
596    /// The moderation policy for the response input.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub input: Option<ModerationPolicySideParam>,
599    /// The moderation policy for the response output.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub output: Option<ModerationPolicySideParam>,
602}
603
604/// The moderation policy for one side (input or output) of the response.
605#[derive(Debug, Serialize, Clone)]
606pub struct ModerationPolicySideParam {
607    /// `score` returns moderation results; `block` additionally blocks
608    /// flagged content.
609    pub mode: ModerationPolicyMode,
610}
611
612/// The moderation policy mode.
613#[derive(Debug, Serialize, Clone, Copy)]
614#[serde(rename_all = "lowercase")]
615pub enum ModerationPolicyMode {
616    Score,
617    Block,
618}
619
620/// Options for prompt caching.
621#[derive(Debug, Serialize, Clone, Default)]
622pub struct PromptCacheOptions {
623    /// Controls whether OpenAI automatically creates an implicit cache
624    /// breakpoint. Defaults to `implicit`.
625    #[serde(skip_serializing_if = "Option::is_none")]
626    pub mode: Option<PromptCacheMode>,
627    /// The minimum lifetime applied to every implicit and explicit cache
628    /// breakpoint written by the request. Defaults to `30m`, currently the
629    /// only supported value.
630    #[serde(skip_serializing_if = "Option::is_none")]
631    pub ttl: Option<PromptCacheTtl>,
632}
633
634/// The prompt cache breakpoint mode.
635#[derive(Debug, Serialize, Clone, Copy)]
636#[serde(rename_all = "lowercase")]
637pub enum PromptCacheMode {
638    Implicit,
639    Explicit,
640}
641
642/// The prompt cache TTL. Currently only `30m` is supported.
643#[derive(Debug, Serialize, Clone, Copy)]
644pub enum PromptCacheTtl {
645    #[serde(rename = "30m")]
646    ThirtyMinutes,
647}
648
649#[derive(Debug, Serialize, Clone)]
650#[serde(tag = "type", rename_all = "lowercase")]
651pub enum AssistantToolCall {
652    Function {
653        /// The ID of the tool call.
654        id: String,
655        /// The function that the model called.
656        function: ToolCallFunction,
657    },
658    Custom {
659        /// The ID of the tool call.
660        id: String,
661        /// The custom tool that the model called.
662        custom: ToolCallCustom,
663    },
664}
665
666#[derive(Debug, Serialize, Clone)]
667pub struct ToolCallFunction {
668    /// The arguments to call the function with, as generated by the model in JSON
669    /// format. Note that the model does not always generate valid JSON, and may
670    /// hallucinate parameters not defined by your function schema. Validate the
671    /// arguments in your code before calling your function.
672    arguments: String,
673    /// The name of the function to call.
674    name: String,
675}
676
677#[derive(Debug, Serialize, Clone)]
678pub struct ToolCallCustom {
679    /// The input for the custom tool call generated by the model.
680    input: String,
681    /// The name of the custom tool to call.
682    name: String,
683}
684
685/// Data about a previous audio response from the model, referenced in an
686/// assistant message for multi-turn audio conversations.
687#[derive(Debug, Serialize, Clone)]
688pub struct AssistantAudio {
689    /// Unique identifier for a previous audio response in a multi-turn
690    /// conversation.
691    pub id: String,
692    /// The audio data (base64 encoded) to insert as context. Optional.
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub data: Option<String>,
695}
696
697#[derive(Debug, Serialize, Clone)]
698#[serde(tag = "type", rename_all = "snake_case")]
699pub enum ResponseFormat {
700    /// The type of response format being defined. Always `json_schema`.
701    JsonSchema {
702        /// Structured Outputs configuration options, including a JSON Schema.
703        json_schema: JSONSchema,
704    },
705    /// The type of response format being defined. Always `json_object`.
706    JsonObject,
707    /// The type of response format being defined. Always `text`.
708    Text,
709}
710
711#[derive(Debug, Serialize, Clone)]
712pub struct JSONSchema {
713    /// The name of the response format. Must be a-z, A-Z, 0-9, or contain
714    /// underscores and dashes, with a maximum length of 64.
715    pub name: String,
716    /// A description of what the response format is for, used by the model to determine
717    /// how to respond in the format.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    pub description: Option<String>,
720    /// The schema for the response format, described as a JSON Schema object. Learn how
721    /// to build JSON schemas [here](https://json-schema.org/).
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub schema: Option<serde_json::Map<String, serde_json::Value>>,
724    /// Whether to enable strict schema adherence when generating the output. If set to
725    /// true, the model will always follow the exact schema defined in the `schema`
726    /// field. Only a subset of JSON Schema is supported when `strict` is `true`. To
727    /// learn more, read the
728    /// [Structured Outputs guide](https://platform.openai.com/docs/guides/structured-outputs).
729    #[serde(skip_serializing_if = "Option::is_none")]
730    pub strict: Option<bool>,
731}
732
733#[derive(Serialize, Debug, Clone)]
734#[serde(rename_all = "snake_case")]
735pub enum Modality {
736    Text,
737    Audio,
738}
739
740/// Parameters for audio output of a chat completion.
741#[derive(Serialize, Debug, Clone)]
742pub struct ChatCompletionAudioParam {
743    /// Specifies the output audio format. Must be one of `wav`, `aac`, `mp3`,
744    /// `flac`, `opus`, or `pcm16`.
745    pub format: AudioFormat,
746    /// The voice the model uses to respond.
747    pub voice: Voice,
748}
749
750/// The output audio format of a chat completion.
751#[derive(Serialize, Debug, Clone)]
752#[serde(rename_all = "snake_case")]
753pub enum AudioFormat {
754    Wav,
755    Aac,
756    Mp3,
757    Flac,
758    Opus,
759    Pcm16,
760}
761
762/// The voice the model uses to respond with audio output.
763#[derive(Serialize, Debug, Clone)]
764#[serde(untagged)]
765pub enum Voice {
766    /// A built-in voice name, e.g. `alloy`, `ash`, `ballad`, `coral`, `echo`,
767    /// `sage`, `shimmer`, or `verse`.
768    BuiltIn(String),
769    /// A custom voice reference, e.g. `{ "id": "voice_1234" }`.
770    Custom {
771        /// The custom voice ID, e.g. `voice_1234`.
772        id: String,
773    },
774}
775
776#[derive(Serialize, Debug, Clone)]
777pub struct ChatCompletionPredictionContentParam {
778    /// The content that should be matched when generating a model response. If
779    /// generated tokens would match this content, the entire model response can be
780    /// returned much more quickly.
781    pub content: ChatCompletionPredictionContentParamContent,
782
783    /// The type of the predicted content you want to provide.
784    /// This type is currently always `content`.
785    #[serde(rename = "type")]
786    pub type_: ChatCompletionPredictionContentParamType,
787}
788
789#[derive(Serialize, Debug, Clone)]
790#[serde(untagged)]
791pub enum ChatCompletionPredictionContentParamContent {
792    Text(String),
793    ChatCompletionContentPartTextParam {
794        /// The text content.
795        text: String,
796        /// The type of the content part.
797        #[serde(rename = "type")]
798        type_: ChatCompletionContentPartTextParamType,
799    },
800}
801
802#[derive(Serialize, Debug, Clone)]
803#[serde(rename_all = "snake_case")]
804pub enum ChatCompletionContentPartTextParamType {
805    Text,
806}
807
808#[derive(Serialize, Debug, Clone)]
809#[serde(rename_all = "snake_case")]
810pub enum ChatCompletionPredictionContentParamType {
811    Content,
812}
813
814/// DeepSeek: skip-serialization helper for the Beta `prefix` message field.
815#[cfg(feature = "deepseek")]
816#[inline]
817fn is_false(value: &bool) -> bool {
818    !value
819}
820
821#[derive(Serialize, Debug, Clone)]
822#[serde(untagged)]
823pub enum StopKeywords {
824    Word(String),
825    Words(Vec<String>),
826}
827
828#[derive(Serialize, Debug, Clone)]
829#[serde(rename_all = "snake_case")]
830pub enum LowMediumHighEnum {
831    Low,
832    Medium,
833    High,
834}
835
836#[derive(Serialize, Debug, Clone, Default)]
837pub struct WebSearchOptions {
838    /// High level guidance for the amount of context window space to use for the
839    /// search. One of `low`, `medium`, or `high`. `medium` is the default.
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub search_context_size: Option<LowMediumHighEnum>,
842
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub user_location: Option<WebSearchOptionsUserLocation>,
845}
846
847#[derive(Serialize, Debug, Clone)]
848#[serde(tag = "type", rename_all = "snake_case")]
849pub enum WebSearchOptionsUserLocation {
850    /// The type of location approximation. Always `approximate`.
851    Approximate {
852        /// Approximate location parameters for the search.
853        approximate: WebSearchOptionsUserLocationApproximate,
854    },
855}
856
857#[derive(Serialize, Debug, Clone, Default)]
858pub struct WebSearchOptionsUserLocationApproximate {
859    /// Free text input for the city of the user, e.g. `San Francisco`.
860    #[serde(skip_serializing_if = "Option::is_none")]
861    pub city: Option<String>,
862
863    /// The two-letter [ISO country code](https://en.wikipedia.org/wiki/ISO_3166-1) of
864    /// the user, e.g. `US`.
865    #[serde(skip_serializing_if = "Option::is_none")]
866    pub country: Option<String>,
867
868    /// Free text input for the region of the user, e.g. `California`.
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub region: Option<String>,
871
872    /// The [IANA timezone](https://timeapi.io/documentation/iana-timezones) of the
873    /// user, e.g. `America/Los_Angeles`.
874    #[serde(skip_serializing_if = "Option::is_none")]
875    pub timezone: Option<String>,
876}
877
878#[derive(Serialize, Debug, Clone)]
879pub struct StreamOptions {
880    /// If set, an additional chunk will be streamed before the `data: [DONE]` message.
881    ///
882    /// The `usage` field on this chunk shows the token usage statistics for the entire
883    /// request, and the `choices` field will always be an empty array.
884    ///
885    /// All other chunks will also include a `usage` field, but with a null value.
886    /// **NOTE:** If the stream is interrupted, you may not receive the final usage
887    /// chunk which contains the total token usage for the request.
888    pub include_usage: bool,
889}
890
891#[derive(Serialize, Debug, Clone)]
892#[serde(tag = "type", rename_all = "snake_case")]
893pub enum RequestTool {
894    /// The type of the tool. Currently, only `function` is supported.
895    Function { function: ToolFunction },
896    /// The type of the custom tool. Always `custom`.
897    Custom {
898        /// Properties of the custom tool.
899        custom: ToolCustom,
900    },
901}
902
903#[derive(Serialize, Debug, Clone)]
904pub struct ToolFunction {
905    /// The name of the function to be called. Must be a-z, A-Z, 0-9, or
906    /// contain underscores and dashes, with a maximum length
907    /// of 64.
908    pub name: String,
909    /// A description of what the function does, used by the model to choose when and
910    /// how to call the function.
911    #[serde(skip_serializing_if = "Option::is_none")]
912    pub description: Option<String>,
913    /// The parameters the functions accepts, described as a JSON Schema object.
914    ///
915    /// See the
916    /// [openai function calling guide](https://platform.openai.com/docs/guides/function-calling)
917    /// for examples, and the
918    /// [JSON Schema reference](https://json-schema.org/understanding-json-schema/) for
919    /// documentation about the format.
920    ///
921    /// Omitting `parameters` defines a function with an empty parameter list.
922    #[serde(skip_serializing_if = "Option::is_none")]
923    pub parameters: Option<serde_json::Map<String, serde_json::Value>>,
924    /// Whether to enable strict schema adherence when generating the function call.
925    ///
926    /// If set to true, the model will follow the exact schema defined in the
927    /// `parameters` field. Only a subset of JSON Schema is supported when `strict` is
928    /// `true`. Learn more about Structured Outputs in the
929    /// [openai function calling guide](https://platform.openai.com/docs/guides/function-calling).
930    #[serde(skip_serializing_if = "Option::is_none")]
931    pub strict: Option<bool>,
932}
933
934#[derive(Serialize, Debug, Clone)]
935pub struct ToolCustom {
936    /// The name of the custom tool, used to identify it in tool calls.
937    pub name: String,
938    /// Optional description of the custom tool, used to provide more context.
939    #[serde(skip_serializing_if = "Option::is_none")]
940    pub description: Option<String>,
941    /// The input format for the custom tool. Default is unconstrained text.
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub format: Option<ToolCustomFormat>,
944}
945
946#[derive(Serialize, Debug, Clone)]
947#[serde(rename_all = "snake_case", tag = "type")]
948pub enum ToolCustomFormat {
949    /// Unconstrained text format. Always `text`.
950    Text,
951    /// Grammar format. Always `grammar`.
952    Grammar {
953        /// Your chosen grammar.
954        grammar: ToolCustomFormatGrammarGrammar,
955    },
956}
957
958#[derive(Debug, Serialize, Clone)]
959pub struct ToolCustomFormatGrammarGrammar {
960    /// The grammar definition.
961    pub definition: String,
962    /// The syntax of the grammar definition. One of `lark` or `regex`.
963    pub syntax: ToolCustomFormatGrammarGrammarSyntax,
964}
965
966#[derive(Debug, Serialize, Clone)]
967#[serde(rename_all = "snake_case")]
968pub enum ToolCustomFormatGrammarGrammarSyntax {
969    Lark,
970    Regex,
971}
972
973#[derive(Debug, Serialize, Clone)]
974#[serde(rename_all = "snake_case")]
975pub enum ToolChoice {
976    None,
977    Auto,
978    Required,
979    #[serde(untagged)]
980    Specific(ToolChoiceSpecific),
981}
982
983#[derive(Debug, Serialize, Clone)]
984#[serde(rename_all = "snake_case", tag = "type")]
985pub enum ToolChoiceSpecific {
986    /// Allowed tool configuration type. Always `allowed_tools`.
987    AllowedTools {
988        /// Constrains the tools available to the model to a pre-defined set.
989        allowed_tools: ToolChoiceAllowedTools,
990    },
991    /// For function calling, the type is always `function`.
992    Function { function: ToolChoiceFunction },
993    /// For custom tool calling, the type is always `custom`.
994    Custom { custom: ToolChoiceCustom },
995}
996
997#[derive(Debug, Serialize, Clone)]
998pub struct ToolChoiceAllowedTools {
999    /// Constrains the tools available to the model to a pre-defined set.
1000    ///
1001    /// - `auto` allows the model to pick from among the allowed tools and generate a
1002    ///   message.
1003    /// - `required` requires the model to call one or more of the allowed tools.
1004    pub mode: ToolChoiceAllowedToolsMode,
1005    /// A list of tool definitions that the model should be allowed to call.
1006    ///
1007    /// For the Chat Completions API, the list of tool definitions might look like:
1008    ///
1009    /// ```json
1010    /// [
1011    ///   { "type": "function", "function": { "name": "get_weather" } },
1012    ///   { "type": "function", "function": { "name": "get_time" } }
1013    /// ]
1014    /// ```
1015    pub tools: Vec<serde_json::Map<String, serde_json::Value>>,
1016}
1017
1018/// The mode for allowed tools in tool choice.
1019///
1020/// Controls how the model should handle the set of allowed tools:
1021///
1022/// - `auto` allows the model to pick from among the allowed tools and generate a
1023///   message.
1024/// - `required` requires the model to call one or more of the allowed tools.
1025#[derive(Debug, Serialize, Clone)]
1026#[serde(rename_all = "lowercase")]
1027pub enum ToolChoiceAllowedToolsMode {
1028    /// The model can choose whether to use the allowed tools or not.
1029    Auto,
1030    /// The model must use at least one of the allowed tools.
1031    Required,
1032}
1033
1034#[derive(Debug, Serialize, Clone)]
1035pub struct ToolChoiceFunction {
1036    /// The name of the function to call.
1037    pub name: String,
1038}
1039
1040#[derive(Debug, Serialize, Clone)]
1041pub struct ToolChoiceCustom {
1042    /// The name of the custom tool to call.
1043    pub name: String,
1044}
1045
1046/// DeepSeek: controls the switch between thinking and non-thinking mode.
1047#[cfg(feature = "deepseek")]
1048#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
1049pub struct DeepSeekThinking {
1050    /// Whether to use thinking mode (`enabled`) or non-thinking mode
1051    /// (`disabled`). Defaults to `enabled`.
1052    #[serde(rename = "type")]
1053    pub type_: DeepSeekThinkingType,
1054}
1055
1056/// DeepSeek: whether thinking mode is enabled or disabled.
1057#[cfg(feature = "deepseek")]
1058#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
1059#[serde(rename_all = "lowercase")]
1060pub enum DeepSeekThinkingType {
1061    Enabled,
1062    Disabled,
1063}
1064
1065/// Constrains the effort on reasoning for reasoning models. This is an
1066/// official OpenAI parameter; reasoning providers such as DeepSeek and Qwen
1067/// accept a subset of these values and map the rest to their nearest effort
1068/// level.
1069#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
1070#[serde(rename_all = "lowercase")]
1071pub enum ReasoningEffort {
1072    None,
1073    Minimal,
1074    Low,
1075    Medium,
1076    High,
1077    Xhigh,
1078    Max,
1079}
1080
1081impl RequestBody {
1082    /// Whether this request asks for a streamed response. Defaults to
1083    /// `false` when [`RequestBody::stream`] is `None`.
1084    pub fn is_streaming(&self) -> bool {
1085        self.stream.unwrap_or(false)
1086    }
1087}
1088
1089impl Post for RequestBody {
1090    fn is_streaming(&self) -> bool {
1091        RequestBody::is_streaming(self)
1092    }
1093
1094    /// Builds the URL for the request.
1095    ///
1096    /// `base_url` should be like <https://api.openai.com/v1>
1097    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
1098        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
1099        url.path_segments_mut()
1100            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
1101            .push("chat")
1102            .push("completions");
1103
1104        Ok(url.to_string())
1105    }
1106}
1107
1108impl PostNoStream for RequestBody {
1109    type Response = super::response::no_streaming::ChatCompletion;
1110}
1111
1112impl PostStream for RequestBody {
1113    type Response = super::response::streaming::ChatCompletionChunk;
1114}
1115
1116#[cfg(test)]
1117mod request_test {
1118    use futures_util::StreamExt;
1119
1120    use super::*;
1121
1122    const DEEPSEEK_CHAT_URL: &str = "https://api.deepseek.com";
1123    const DEEPSEEK_MODEL: &str = "deepseek-v4-flash";
1124
1125    fn deepseek_api_key() -> Option<String> {
1126        std::env::var("DEEPSEEK_API_KEY")
1127            .ok()
1128            .map(|key| key.trim().to_string())
1129            .filter(|key| !key.is_empty())
1130    }
1131
1132    #[tokio::test]
1133    async fn test_deepseek_no_stream() {
1134        let Some(api_key) = deepseek_api_key() else {
1135            println!("Skipping: set DEEPSEEK_API_KEY to run this test");
1136            return;
1137        };
1138
1139        let request = RequestBody {
1140            messages: vec![
1141                Message::System {
1142                    content: "This is a request of test purpose. Reply briefly".to_string(),
1143                    name: None,
1144                },
1145                Message::User {
1146                    content: "What's your name?".into(),
1147                    name: None,
1148                },
1149            ],
1150            model: DEEPSEEK_MODEL.to_string(),
1151            stream: Some(false),
1152            ..Default::default()
1153        };
1154
1155        let response = request
1156            .get_response_string(&crate::rest::default_client(), DEEPSEEK_CHAT_URL, &api_key)
1157            .await
1158            .unwrap();
1159
1160        println!("{}", response);
1161
1162        assert!(response.to_ascii_lowercase().contains("deepseek"));
1163    }
1164
1165    #[tokio::test]
1166    async fn test_deepseek_stream() {
1167        let Some(api_key) = deepseek_api_key() else {
1168            println!("Skipping: set DEEPSEEK_API_KEY to run this test");
1169            return;
1170        };
1171
1172        let request = RequestBody {
1173            messages: vec![
1174                Message::System {
1175                    content: "This is a request of test purpose. Reply briefly".to_string(),
1176                    name: None,
1177                },
1178                Message::User {
1179                    content: "Who are you?".into(),
1180                    name: None,
1181                },
1182            ],
1183            model: DEEPSEEK_MODEL.to_string(),
1184            stream: Some(true),
1185            ..Default::default()
1186        };
1187
1188        let mut response = request
1189            .get_stream_response_string(&crate::rest::default_client(), DEEPSEEK_CHAT_URL, &api_key)
1190            .await
1191            .unwrap();
1192
1193        while let Some(chunk) = response.next().await {
1194            println!("{}", chunk.unwrap());
1195        }
1196    }
1197
1198    /// Assistant tool calls serialize with the official `type` tag
1199    /// (`{"type":"function",...}` / `{"type":"custom",...}`), not `role`.
1200    #[test]
1201    fn assistant_tool_call_serialization() {
1202        let function_call = AssistantToolCall::Function {
1203            id: "call_abc".to_string(),
1204            function: ToolCallFunction {
1205                arguments: "{\"city\":\"paris\"}".to_string(),
1206                name: "get_weather".to_string(),
1207            },
1208        };
1209        let json = serde_json::to_string(&function_call).unwrap();
1210        assert!(json.contains(r#""type":"function""#), "json: {json}");
1211        assert!(!json.contains(r#""role""#), "json: {json}");
1212
1213        let custom_call = AssistantToolCall::Custom {
1214            id: "call_def".to_string(),
1215            custom: ToolCallCustom {
1216                input: "2+2".to_string(),
1217                name: "calculator".to_string(),
1218            },
1219        };
1220        let json = serde_json::to_string(&custom_call).unwrap();
1221        assert!(json.contains(r#""type":"custom""#), "json: {json}");
1222        assert!(!json.contains(r#""role""#), "json: {json}");
1223    }
1224
1225    /// The `prediction` parameter sends its discriminator as `type`, not
1226    /// as the Rust field name `type_`.
1227    #[test]
1228    fn prediction_type_serialization() {
1229        let prediction = ChatCompletionPredictionContentParam {
1230            content: ChatCompletionPredictionContentParamContent::Text(
1231                "The capital of France is Paris.".to_string(),
1232            ),
1233            type_: ChatCompletionPredictionContentParamType::Content,
1234        };
1235        let json = serde_json::to_string(&prediction).unwrap();
1236        assert!(json.contains(r#""type":"content""#), "json: {json}");
1237        assert!(!json.contains("type_"), "json: {json}");
1238    }
1239
1240    /// `tool_choice: allowed_tools` sends `tools` as a JSON array of tool
1241    /// definitions, matching the official `Iterable[Dict[str, object]]`.
1242    #[test]
1243    fn allowed_tools_choice_serialization() {
1244        let mut weather = serde_json::Map::new();
1245        weather.insert("type".to_string(), serde_json::json!("function"));
1246        weather.insert(
1247            "function".to_string(),
1248            serde_json::json!({ "name": "get_weather" }),
1249        );
1250
1251        let choice = ToolChoiceSpecific::AllowedTools {
1252            allowed_tools: ToolChoiceAllowedTools {
1253                mode: ToolChoiceAllowedToolsMode::Required,
1254                tools: vec![weather],
1255            },
1256        };
1257        let json = serde_json::to_string(&choice).unwrap();
1258        assert!(json.contains(r#""type":"allowed_tools""#), "json: {json}");
1259        assert!(json.contains(r#""mode":"required""#), "json: {json}");
1260        // `tools` must serialize as an array, not an object.
1261        assert!(json.contains(r#""tools":[{"#), "json: {json}");
1262    }
1263
1264    /// `web_search_options` sends `search_context_size` as optional and the
1265    /// user location nested under an `approximate` key.
1266    #[test]
1267    fn web_search_options_serialization() {
1268        let options = WebSearchOptions {
1269            search_context_size: None,
1270            user_location: Some(WebSearchOptionsUserLocation::Approximate {
1271                approximate: WebSearchOptionsUserLocationApproximate {
1272                    city: Some("San Francisco".to_string()),
1273                    country: None,
1274                    region: None,
1275                    timezone: None,
1276                },
1277            }),
1278        };
1279        let json = serde_json::to_string(&options).unwrap();
1280        assert!(!json.contains("search_context_size"), "json: {json}");
1281        assert!(json.contains(r#""type":"approximate""#), "json: {json}");
1282        assert!(
1283            json.contains(r#""approximate":{"city":"San Francisco"}"#),
1284            "json: {json}"
1285        );
1286    }
1287
1288    /// `JSONSchema`/`ToolFunction` optional fields are omitted when unset.
1289    #[test]
1290    fn json_schema_optional_fields_serialization() {
1291        let schema = JSONSchema {
1292            name: "Answer".to_string(),
1293            description: None,
1294            schema: None,
1295            strict: None,
1296        };
1297        let json = serde_json::to_string(&schema).unwrap();
1298        assert_eq!(json, r#"{"name":"Answer"}"#);
1299
1300        let function = ToolFunction {
1301            name: "get_weather".to_string(),
1302            description: None,
1303            parameters: None,
1304            strict: None,
1305        };
1306        let json = serde_json::to_string(&function).unwrap();
1307        assert_eq!(json, r#"{"name":"get_weather"}"#);
1308    }
1309
1310    /// Plain-text user messages keep the official wire format: `content`
1311    /// is a JSON string, not a parts array.
1312    #[test]
1313    fn user_text_content_serialization() {
1314        let request = RequestBody {
1315            messages: vec![Message::User {
1316                content: "Hi".into(),
1317                name: None,
1318            }],
1319            model: "gpt-4o".to_string(),
1320            ..Default::default()
1321        };
1322
1323        let json = serde_json::to_string(&request).unwrap();
1324        assert!(json.contains(r#""content":"Hi""#), "json: {json}");
1325    }
1326
1327    /// Multimodal user messages serialize as content-part arrays with the
1328    /// official shapes, including `prompt_cache_breakpoint`.
1329    #[test]
1330    fn multimodal_content_serialization() {
1331        let request = RequestBody {
1332            messages: vec![Message::User {
1333                content: MessageContent::Parts(vec![
1334                    ContentPart::ImageUrl {
1335                        image_url: ContentPartImageUrl {
1336                            url: "https://example.com/cat.png".to_string(),
1337                            detail: Some(ImageDetail::High),
1338                        },
1339                        prompt_cache_breakpoint: None,
1340                    },
1341                    ContentPart::Text {
1342                        text: "What's in this image?".to_string(),
1343                        prompt_cache_breakpoint: Some(PromptCacheBreakpoint {
1344                            mode: PromptCacheBreakpointMode::Explicit,
1345                        }),
1346                    },
1347                ]),
1348                name: None,
1349            }],
1350            model: "gpt-4o".to_string(),
1351            ..Default::default()
1352        };
1353
1354        let json = serde_json::to_string(&request).unwrap();
1355        assert!(json.contains(r#""type":"image_url""#), "json: {json}");
1356        assert!(
1357            json.contains(r#""url":"https://example.com/cat.png""#),
1358            "json: {json}"
1359        );
1360        assert!(json.contains(r#""detail":"high""#), "json: {json}");
1361        assert!(json.contains(r#""type":"text""#), "json: {json}");
1362        assert!(
1363            json.contains(r#""prompt_cache_breakpoint":{"mode":"explicit"}"#),
1364            "json: {json}"
1365        );
1366    }
1367
1368    /// `input_audio` and `file` content parts serialize with the official
1369    /// shapes.
1370    #[test]
1371    fn audio_and_file_content_serialization() {
1372        let content = MessageContent::Parts(vec![
1373            ContentPart::InputAudio {
1374                input_audio: ContentPartInputAudio {
1375                    data: "aGVsbG8=".to_string(),
1376                    format: InputAudioFormat::Wav,
1377                },
1378                prompt_cache_breakpoint: None,
1379            },
1380            ContentPart::File {
1381                file: ContentPartFile {
1382                    file_id: Some("file-abc".to_string()),
1383                    ..Default::default()
1384                },
1385                prompt_cache_breakpoint: None,
1386            },
1387        ]);
1388
1389        let json = serde_json::to_string(&content).unwrap();
1390        assert!(json.contains(r#""type":"input_audio""#), "json: {json}");
1391        assert!(json.contains(r#""data":"aGVsbG8=""#), "json: {json}");
1392        assert!(json.contains(r#""format":"wav""#), "json: {json}");
1393        assert!(json.contains(r#""type":"file""#), "json: {json}");
1394        assert!(
1395            json.contains(r#""file":{"file_id":"file-abc"}"#),
1396            "json: {json}"
1397        );
1398        // Optional file fields are omitted when unset.
1399        assert!(!json.contains("file_data"), "json: {json}");
1400    }
1401
1402    /// `logit_bias`, `moderation` and `prompt_cache_options` serialize as
1403    /// the official request parameters (token-id keys as JSON strings).
1404    #[test]
1405    fn new_params_serialization() {
1406        let mut logit_bias = HashMap::new();
1407        logit_bias.insert(40u32, -100i32);
1408
1409        let request = RequestBody {
1410            messages: vec![Message::User {
1411                content: "Hi".into(),
1412                name: None,
1413            }],
1414            model: "gpt-5".to_string(),
1415            logit_bias: Some(logit_bias),
1416            moderation: Some(ChatModerationParam {
1417                model: "omni-moderation-latest".to_string(),
1418                policy: Some(ModerationPolicyParam {
1419                    input: Some(ModerationPolicySideParam {
1420                        mode: ModerationPolicyMode::Block,
1421                    }),
1422                    output: None,
1423                }),
1424            }),
1425            prompt_cache_options: Some(PromptCacheOptions {
1426                mode: Some(PromptCacheMode::Explicit),
1427                ttl: Some(PromptCacheTtl::ThirtyMinutes),
1428            }),
1429            ..Default::default()
1430        };
1431
1432        let json = serde_json::to_string(&request).unwrap();
1433        assert!(json.contains(r#""logit_bias":{"40":-100}"#), "json: {json}");
1434        assert!(
1435            json.contains(
1436                r#""moderation":{"model":"omni-moderation-latest","policy":{"input":{"mode":"block"}}}"#
1437            ),
1438            "json: {json}"
1439        );
1440        assert!(
1441            json.contains(r#""prompt_cache_options":{"mode":"explicit","ttl":"30m"}"#),
1442            "json: {json}"
1443        );
1444    }
1445
1446    /// Serializes the OpenAI `reasoning_effort` parameter.
1447    #[test]
1448    fn reasoning_effort_serialization() {
1449        let request = RequestBody {
1450            messages: vec![Message::User {
1451                content: "What's your name?".into(),
1452                name: None,
1453            }],
1454            model: "gpt-5".to_string(),
1455            reasoning_effort: Some(ReasoningEffort::Xhigh),
1456            ..Default::default()
1457        };
1458
1459        let json = serde_json::to_string(&request).unwrap();
1460        assert!(
1461            json.contains(r#""reasoning_effort":"xhigh""#),
1462            "json: {json}"
1463        );
1464    }
1465
1466    /// Serializes the DeepSeek Beta chat prefix completion fields.
1467    #[cfg(feature = "deepseek")]
1468    #[test]
1469    fn deepseek_assistant_prefix_serialization() {
1470        let request = RequestBody {
1471            messages: vec![
1472                Message::User {
1473                    content: "Please write quick sort code".into(),
1474                    name: None,
1475                },
1476                Message::Assistant {
1477                    content: Some("```python\n".to_string()),
1478                    audio: None,
1479                    refusal: None,
1480                    name: None,
1481                    prefix: true,
1482                    reasoning_content: None,
1483                    tool_calls: None,
1484                },
1485            ],
1486            model: DEEPSEEK_MODEL.to_string(),
1487            ..Default::default()
1488        };
1489
1490        let json = serde_json::to_string(&request).unwrap();
1491        assert!(json.contains(r#""prefix":true"#), "json: {json}");
1492    }
1493
1494    /// Serializes the DeepSeek `thinking`, `reasoning_effort` and `user_id`
1495    /// request parameters.
1496    #[cfg(feature = "deepseek")]
1497    #[test]
1498    fn deepseek_thinking_params_serialization() {
1499        let request = RequestBody {
1500            messages: vec![Message::User {
1501                content: "What's your name?".into(),
1502                name: None,
1503            }],
1504            model: DEEPSEEK_MODEL.to_string(),
1505            thinking: Some(DeepSeekThinking {
1506                type_: DeepSeekThinkingType::Disabled,
1507            }),
1508            user_id: Some("user-123".to_string()),
1509            ..Default::default()
1510        };
1511
1512        let json = serde_json::to_string(&request).unwrap();
1513        assert!(
1514            json.contains(r#""thinking":{"type":"disabled"}"#),
1515            "json: {json}"
1516        );
1517        assert!(json.contains(r#""user_id":"user-123""#), "json: {json}");
1518    }
1519
1520    /// Serializes the Qwen `enable_thinking`, `thinking_budget` and `top_k`
1521    /// request parameters.
1522    #[cfg(feature = "qwen")]
1523    #[test]
1524    fn qwen_params_serialization() {
1525        let request = RequestBody {
1526            messages: vec![Message::User {
1527                content: "What's your name?".into(),
1528                name: None,
1529            }],
1530            model: "qwen-plus".to_string(),
1531            enable_thinking: Some(false),
1532            thinking_budget: Some(1024),
1533            top_k: Some(20),
1534            ..Default::default()
1535        };
1536
1537        let json = serde_json::to_string(&request).unwrap();
1538        assert!(json.contains(r#""enable_thinking":false"#), "json: {json}");
1539        assert!(json.contains(r#""thinking_budget":1024"#), "json: {json}");
1540        assert!(json.contains(r#""top_k":20"#), "json: {json}");
1541    }
1542
1543    const QWEN_CHAT_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
1544    /// Qwen's multimodal flash model: accepts text, image and audio inputs
1545    /// through its OpenAI-compatible endpoint.
1546    const QWEN_MULTIMODAL_MODEL: &str = "qwen3.8-flash";
1547
1548    fn qwen_api_key() -> Option<String> {
1549        std::env::var("QWEN_API_KEY")
1550            .ok()
1551            .map(|key| key.trim().to_string())
1552            .filter(|key| !key.is_empty())
1553    }
1554
1555    /// Real request: a user message with an `image_url` content part. The
1556    /// image is the football sample used in Alibaba Cloud Model Studio's own
1557    /// documentation. Requires `QWEN_API_KEY`; skipped otherwise.
1558    #[tokio::test]
1559    async fn test_qwen_image_input() -> Result<(), anyhow::Error> {
1560        let Some(api_key) = qwen_api_key() else {
1561            println!("Skipping: set QWEN_API_KEY to run this test");
1562            return Ok(());
1563        };
1564
1565        let request = RequestBody {
1566            messages: vec![
1567                Message::System {
1568                    content: "This is a request of test purpose. Reply briefly".to_string(),
1569                    name: None,
1570                },
1571                Message::User {
1572                    content: MessageContent::Parts(vec![
1573                        ContentPart::ImageUrl {
1574                            image_url: ContentPartImageUrl {
1575                                url: "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/xzsgiz/football1.jpg"
1576                                    .to_string(),
1577                                detail: None,
1578                            },
1579                            prompt_cache_breakpoint: None,
1580                        },
1581                        ContentPart::Text {
1582                            text: "What is shown in this image? Answer with one short sentence."
1583                                .to_string(),
1584                            prompt_cache_breakpoint: None,
1585                        },
1586                    ]),
1587                    name: None,
1588                },
1589            ],
1590            model: QWEN_MULTIMODAL_MODEL.to_string(),
1591            ..Default::default()
1592        };
1593
1594        let response = request
1595            .get_response(&crate::rest::default_client(), QWEN_CHAT_URL, &api_key)
1596            .await?;
1597
1598        let content = response.choices[0]
1599            .message
1600            .content
1601            .clone()
1602            .unwrap_or_default();
1603        println!("image response: {content}");
1604        assert!(
1605            !content.trim().is_empty(),
1606            "empty content for a valid image request"
1607        );
1608        Ok(())
1609    }
1610
1611    /// Real request: a user message with an `input_audio` content part
1612    /// carrying a public audio URL (the cherry sample from the Model Studio
1613    /// docs), answered by the streaming response. Requires `QWEN_API_KEY`;
1614    /// skipped otherwise.
1615    ///
1616    /// Uses `qwen-omni-turbo`: Qwen's Omni models are the multimodal class
1617    /// that accepts audio input on the OpenAI-compatible endpoint, and they
1618    /// require `stream: true`. (`qwen3.8-flash` rejects `input_audio` with a
1619    /// provider-side `400 incorrect modal 'audio'` error, verified with
1620    /// plain curl.)
1621    #[tokio::test]
1622    async fn test_qwen_audio_input() -> Result<(), anyhow::Error> {
1623        let Some(api_key) = qwen_api_key() else {
1624            println!("Skipping: set QWEN_API_KEY to run this test");
1625            return Ok(());
1626        };
1627
1628        let request = RequestBody {
1629            messages: vec![Message::User {
1630                content: MessageContent::Parts(vec![
1631                    ContentPart::InputAudio {
1632                        input_audio: ContentPartInputAudio {
1633                            data: "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250211/tixcef/cherry.wav"
1634                                .to_string(),
1635                            format: InputAudioFormat::Wav,
1636                        },
1637                        prompt_cache_breakpoint: None,
1638                    },
1639                    ContentPart::Text {
1640                        text: "What does the speaker say in this audio? Reply briefly."
1641                            .to_string(),
1642                        prompt_cache_breakpoint: None,
1643                    },
1644                ]),
1645                name: None,
1646            }],
1647            model: "qwen-omni-turbo".to_string(),
1648            stream: Some(true),
1649            modalities: Some(vec![Modality::Text]),
1650            ..Default::default()
1651        };
1652
1653        let mut stream = request
1654            .get_stream_response(&crate::rest::default_client(), QWEN_CHAT_URL, &api_key)
1655            .await?;
1656
1657        let mut message = String::new();
1658        while let Some(chunk) = stream.next().await {
1659            let chunk = chunk?;
1660            if let Some(choice) = chunk.choices.first()
1661                && let Some(content) = choice.delta.content.as_deref()
1662            {
1663                message.push_str(content);
1664            }
1665        }
1666
1667        println!("audio response: {message}");
1668        assert!(
1669            !message.trim().is_empty(),
1670            "empty content for a valid audio request"
1671        );
1672        Ok(())
1673    }
1674
1675    /// Real request: a plain-text user message (the wire format of
1676    /// [`MessageContent::Text`]). Requires `QWEN_API_KEY`; skipped otherwise.
1677    #[tokio::test]
1678    async fn test_qwen_text_input() -> Result<(), anyhow::Error> {
1679        let Some(api_key) = qwen_api_key() else {
1680            println!("Skipping: set QWEN_API_KEY to run this test");
1681            return Ok(());
1682        };
1683
1684        let request = RequestBody {
1685            messages: vec![Message::User {
1686                content: "Reply with exactly one word.".into(),
1687                name: None,
1688            }],
1689            model: QWEN_MULTIMODAL_MODEL.to_string(),
1690            ..Default::default()
1691        };
1692
1693        let response = request
1694            .get_response(&crate::rest::default_client(), QWEN_CHAT_URL, &api_key)
1695            .await?;
1696
1697        let content = response.choices[0]
1698            .message
1699            .content
1700            .clone()
1701            .unwrap_or_default();
1702        println!("text response: {content}");
1703        assert!(!content.trim().is_empty(), "empty content for text input");
1704        Ok(())
1705    }
1706
1707    /// Real request: streaming a multimodal (image + text) user message.
1708    /// Requires `QWEN_API_KEY`; skipped otherwise.
1709    #[tokio::test]
1710    async fn test_qwen_multimodal_stream() -> Result<(), anyhow::Error> {
1711        let Some(api_key) = qwen_api_key() else {
1712            println!("Skipping: set QWEN_API_KEY to run this test");
1713            return Ok(());
1714        };
1715
1716        let request = RequestBody {
1717            messages: vec![Message::User {
1718                content: MessageContent::Parts(vec![
1719                    ContentPart::ImageUrl {
1720                        image_url: ContentPartImageUrl {
1721                            url: "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/xzsgiz/football1.jpg"
1722                                .to_string(),
1723                            detail: None,
1724                        },
1725                        prompt_cache_breakpoint: None,
1726                    },
1727                    ContentPart::Text {
1728                        text: "What is shown in this image? Answer with one short sentence."
1729                            .to_string(),
1730                        prompt_cache_breakpoint: None,
1731                    },
1732                ]),
1733                name: None,
1734            }],
1735            model: QWEN_MULTIMODAL_MODEL.to_string(),
1736            stream: Some(true),
1737            ..Default::default()
1738        };
1739
1740        let mut stream = request
1741            .get_stream_response(&crate::rest::default_client(), QWEN_CHAT_URL, &api_key)
1742            .await?;
1743
1744        let mut message = String::new();
1745        while let Some(chunk) = stream.next().await {
1746            let chunk = chunk?;
1747            if let Some(choice) = chunk.choices.first()
1748                && let Some(content) = choice.delta.content.as_deref()
1749            {
1750                message.push_str(content);
1751            }
1752        }
1753
1754        println!("streamed message: {message}");
1755        assert!(
1756            !message.trim().is_empty(),
1757            "empty streamed content for a valid image request"
1758        );
1759        Ok(())
1760    }
1761}