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, RequestOptions};
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".into(),
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, &RequestOptions::bearer("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: plain text, or an array of
348        /// text content parts.
349        content: MessageContent,
350        /// An optional name for the participant.
351        ///
352        /// Provides the model information to differentiate between
353        /// participants of the same role.
354        #[serde(skip_serializing_if = "Option::is_none")]
355        name: Option<String>,
356    },
357    /// In this case, the role of the message author is `user`.
358    /// The field `{ role = "user" }` is added automatically.
359    User {
360        /// The contents of the user message: plain text, or an array of
361        /// multimodal content parts (`text`, `image_url`, `input_audio`,
362        /// `file`).
363        content: MessageContent,
364        /// An optional name for the participant.
365        ///
366        /// Provides the model information to differentiate between
367        /// participants of the same role.
368        #[serde(skip_serializing_if = "Option::is_none")]
369        name: Option<String>,
370    },
371    /// In this case, the role of the message author is `assistant`.
372    /// The field `{ role = "assistant" }` is added automatically.
373    Assistant {
374        /// The contents of the assistant message. Required unless `tool_calls`
375        /// or `function_call` is specified. (Note that `function_call` is deprecated
376        /// in favour of `tool_calls`.)
377        content: Option<String>,
378        /// Data about a previous audio response from the model. Required for
379        /// multi-turn audio conversations.
380        #[serde(skip_serializing_if = "Option::is_none")]
381        audio: Option<AssistantAudio>,
382        /// The refusal message by the assistant.
383        #[serde(skip_serializing_if = "Option::is_none")]
384        refusal: Option<String>,
385        #[serde(skip_serializing_if = "Option::is_none")]
386        name: Option<String>,
387        /// DeepSeek (Beta): set this to `true` to force the model to start its
388        /// answer by the content of the supplied prefix in this assistant
389        /// message. Requires `base_url = "https://api.deepseek.com/beta"`.
390        #[cfg(feature = "deepseek")]
391        #[serde(skip_serializing_if = "is_false")]
392        prefix: bool,
393        /// DeepSeek (Beta): used for the thinking mode in the
394        /// [Chat Prefix Completion](https://api-docs.deepseek.com/guides/chat_prefix_completion)
395        /// feature as the input for the CoT in the last assistant message.
396        /// When using this feature, `prefix` must be set to `true`.
397        #[cfg(feature = "deepseek")]
398        #[serde(skip_serializing_if = "Option::is_none")]
399        reasoning_content: Option<String>,
400
401        /// The tool calls generated by the model, such as function calls.
402        #[serde(skip_serializing_if = "Option::is_none")]
403        tool_calls: Option<Vec<AssistantToolCall>>,
404    },
405    /// In this case, the role of the message author is `assistant`.
406    /// The field `{ role = "tool" }` is added automatically.
407    Tool {
408        /// The contents of the tool message: plain text, or an array of
409        /// text content parts.
410        content: MessageContent,
411        /// Tool call that this message is responding to.
412        tool_call_id: String,
413    },
414    /// In this case, the role of the message author is `function`.
415    /// The field `{ role = "function" }` is added automatically.
416    Function {
417        /// The contents of the function message.
418        content: String,
419        /// The name of the function to call.
420        name: String,
421    },
422    /// In this case, the role of the message author is `developer`.
423    /// The field `{ role = "developer" }` is added automatically.
424    Developer {
425        /// The contents of the developer message: plain text, or an array of
426        /// text content parts.
427        content: MessageContent,
428        /// An optional name for the participant.
429        ///
430        /// Provides the model information to differentiate between
431        /// participants of the same role.
432        name: Option<String>,
433    },
434}
435
436/// The contents of a user message: either plain text, or an array of
437/// multimodal content parts.
438#[derive(Debug, Serialize, Clone)]
439#[serde(untagged)]
440pub enum MessageContent {
441    /// A plain-text message content.
442    Text(String),
443    /// An array of multimodal content parts (`text`, `image_url`,
444    /// `input_audio`, `file`).
445    Parts(Vec<ContentPart>),
446}
447
448impl From<&str> for MessageContent {
449    fn from(value: &str) -> Self {
450        Self::Text(value.to_string())
451    }
452}
453
454impl From<String> for MessageContent {
455    fn from(value: String) -> Self {
456        Self::Text(value)
457    }
458}
459
460impl From<Vec<ContentPart>> for MessageContent {
461    fn from(value: Vec<ContentPart>) -> Self {
462        Self::Parts(value)
463    }
464}
465
466impl Default for MessageContent {
467    fn default() -> Self {
468        Self::Text(String::new())
469    }
470}
471
472/// A content part of a multimodal user message.
473#[derive(Debug, Serialize, Clone)]
474#[serde(tag = "type", rename_all = "snake_case")]
475pub enum ContentPart {
476    /// Learn about [text inputs](https://platform.openai.com/docs/guides/text).
477    Text {
478        /// The text content.
479        text: String,
480        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
481        /// from the request's `prompt_cache_options.ttl`.
482        #[serde(skip_serializing_if = "Option::is_none")]
483        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
484    },
485    /// Learn about [image inputs](https://platform.openai.com/docs/guides/vision).
486    ImageUrl {
487        /// Contains either an image URL or a data URL for a base64 encoded image.
488        image_url: ContentPartImageUrl,
489        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
490        /// from the request's `prompt_cache_options.ttl`.
491        #[serde(skip_serializing_if = "Option::is_none")]
492        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
493    },
494    /// Learn about [audio inputs](https://platform.openai.com/docs/guides/audio).
495    InputAudio {
496        /// The audio input data and its format.
497        input_audio: ContentPartInputAudio,
498        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
499        /// from the request's `prompt_cache_options.ttl`.
500        #[serde(skip_serializing_if = "Option::is_none")]
501        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
502    },
503    /// Learn about [file inputs](https://platform.openai.com/docs/guides/text).
504    File {
505        /// The file input: base64 data, an uploaded file ID, or both with a
506        /// filename.
507        file: ContentPartFile,
508        /// Marks the exact end of a reusable prompt prefix. Inherits its TTL
509        /// from the request's `prompt_cache_options.ttl`.
510        #[serde(skip_serializing_if = "Option::is_none")]
511        prompt_cache_breakpoint: Option<PromptCacheBreakpoint>,
512    },
513}
514
515/// Marks the exact end of a reusable prompt prefix.
516#[derive(Debug, Serialize, Clone)]
517pub struct PromptCacheBreakpoint {
518    /// The breakpoint mode. Always `explicit`.
519    pub mode: PromptCacheBreakpointMode,
520}
521
522/// The breakpoint mode. Always `explicit`.
523#[derive(Debug, Serialize, Clone)]
524#[serde(rename_all = "lowercase")]
525pub enum PromptCacheBreakpointMode {
526    Explicit,
527}
528
529/// Contains either an image URL or a data URL for a base64 encoded image.
530#[derive(Debug, Serialize, Clone)]
531pub struct ContentPartImageUrl {
532    /// Either a URL of the image or the base64 encoded image data.
533    pub url: String,
534    /// Specifies the detail level of the image.
535    /// [Learn more](https://platform.openai.com/docs/guides/vision#low-or-high-fidelity-image-understanding).
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub detail: Option<ImageDetail>,
538}
539
540/// The detail level of an image input.
541#[derive(Debug, Serialize, Clone, Copy)]
542#[serde(rename_all = "lowercase")]
543pub enum ImageDetail {
544    Auto,
545    Low,
546    High,
547}
548
549/// Base64 encoded audio input data.
550#[derive(Debug, Serialize, Clone)]
551pub struct ContentPartInputAudio {
552    /// Base64 encoded audio data.
553    pub data: String,
554    /// The format of the encoded audio data. Currently supports `wav` and
555    /// `mp3`.
556    pub format: InputAudioFormat,
557}
558
559/// The format of the encoded audio data.
560#[derive(Debug, Serialize, Clone, Copy)]
561#[serde(rename_all = "lowercase")]
562pub enum InputAudioFormat {
563    Wav,
564    Mp3,
565}
566
567/// A file input for a content part. At least one of `file_data` and
568/// `file_id` should be provided.
569#[derive(Debug, Serialize, Clone, Default)]
570pub struct ContentPartFile {
571    /// The base64 encoded file data, used when passing the file to the model
572    /// as a string.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub file_data: Option<String>,
575    /// The ID of an uploaded file to use as input.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub file_id: Option<String>,
578    /// The name of the file, used when passing the file to the model as a
579    /// string.
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub filename: Option<String>,
582}
583
584/// Configuration for running moderation on the request input and generated
585/// output.
586#[derive(Debug, Serialize, Clone)]
587pub struct ChatModerationParam {
588    /// The moderation model to use for moderated completions, e.g.
589    /// `omni-moderation-latest`.
590    pub model: String,
591    /// The policy to apply to moderated response input and output.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub policy: Option<ModerationPolicyParam>,
594}
595
596/// The policy to apply to moderated response input and output.
597#[derive(Debug, Serialize, Clone, Default)]
598pub struct ModerationPolicyParam {
599    /// The moderation policy for the response input.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub input: Option<ModerationPolicySideParam>,
602    /// The moderation policy for the response output.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub output: Option<ModerationPolicySideParam>,
605}
606
607/// The moderation policy for one side (input or output) of the response.
608#[derive(Debug, Serialize, Clone)]
609pub struct ModerationPolicySideParam {
610    /// `score` returns moderation results; `block` additionally blocks
611    /// flagged content.
612    pub mode: ModerationPolicyMode,
613}
614
615/// The moderation policy mode.
616#[derive(Debug, Serialize, Clone, Copy)]
617#[serde(rename_all = "lowercase")]
618pub enum ModerationPolicyMode {
619    Score,
620    Block,
621}
622
623/// Options for prompt caching.
624#[derive(Debug, Serialize, Clone, Default)]
625pub struct PromptCacheOptions {
626    /// Controls whether OpenAI automatically creates an implicit cache
627    /// breakpoint. Defaults to `implicit`.
628    #[serde(skip_serializing_if = "Option::is_none")]
629    pub mode: Option<PromptCacheMode>,
630    /// The minimum lifetime applied to every implicit and explicit cache
631    /// breakpoint written by the request. Defaults to `30m`, currently the
632    /// only supported value.
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub ttl: Option<PromptCacheTtl>,
635}
636
637/// The prompt cache breakpoint mode.
638#[derive(Debug, Serialize, Clone, Copy)]
639#[serde(rename_all = "lowercase")]
640pub enum PromptCacheMode {
641    Implicit,
642    Explicit,
643}
644
645/// The prompt cache TTL. Currently only `30m` is supported.
646#[derive(Debug, Serialize, Clone, Copy)]
647pub enum PromptCacheTtl {
648    #[serde(rename = "30m")]
649    ThirtyMinutes,
650}
651
652#[derive(Debug, Serialize, Clone)]
653#[serde(tag = "type", rename_all = "lowercase")]
654pub enum AssistantToolCall {
655    Function {
656        /// The ID of the tool call.
657        id: String,
658        /// The function that the model called.
659        function: ToolCallFunction,
660    },
661    Custom {
662        /// The ID of the tool call.
663        id: String,
664        /// The custom tool that the model called.
665        custom: ToolCallCustom,
666    },
667}
668
669#[derive(Debug, Serialize, Clone)]
670pub struct ToolCallFunction {
671    /// The arguments to call the function with, as generated by the model in JSON
672    /// format. Note that the model does not always generate valid JSON, and may
673    /// hallucinate parameters not defined by your function schema. Validate the
674    /// arguments in your code before calling your function.
675    arguments: String,
676    /// The name of the function to call.
677    name: String,
678}
679
680#[derive(Debug, Serialize, Clone)]
681pub struct ToolCallCustom {
682    /// The input for the custom tool call generated by the model.
683    input: String,
684    /// The name of the custom tool to call.
685    name: String,
686}
687
688/// Data about a previous audio response from the model, referenced in an
689/// assistant message for multi-turn audio conversations.
690#[derive(Debug, Serialize, Clone)]
691pub struct AssistantAudio {
692    /// Unique identifier for a previous audio response in a multi-turn
693    /// conversation.
694    pub id: String,
695    /// The audio data (base64 encoded) to insert as context. Optional.
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub data: Option<String>,
698}
699
700#[derive(Debug, Serialize, Clone)]
701#[serde(tag = "type", rename_all = "snake_case")]
702pub enum ResponseFormat {
703    /// The type of response format being defined. Always `json_schema`.
704    JsonSchema {
705        /// Structured Outputs configuration options, including a JSON Schema.
706        json_schema: JSONSchema,
707    },
708    /// The type of response format being defined. Always `json_object`.
709    JsonObject,
710    /// The type of response format being defined. Always `text`.
711    Text,
712}
713
714#[derive(Debug, Serialize, Clone)]
715pub struct JSONSchema {
716    /// The name of the response format. Must be a-z, A-Z, 0-9, or contain
717    /// underscores and dashes, with a maximum length of 64.
718    pub name: String,
719    /// A description of what the response format is for, used by the model to determine
720    /// how to respond in the format.
721    #[serde(skip_serializing_if = "Option::is_none")]
722    pub description: Option<String>,
723    /// The schema for the response format, described as a JSON Schema object. Learn how
724    /// to build JSON schemas [here](https://json-schema.org/).
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub schema: Option<serde_json::Map<String, serde_json::Value>>,
727    /// Whether to enable strict schema adherence when generating the output. If set to
728    /// true, the model will always follow the exact schema defined in the `schema`
729    /// field. Only a subset of JSON Schema is supported when `strict` is `true`. To
730    /// learn more, read the
731    /// [Structured Outputs guide](https://platform.openai.com/docs/guides/structured-outputs).
732    #[serde(skip_serializing_if = "Option::is_none")]
733    pub strict: Option<bool>,
734}
735
736#[derive(Serialize, Debug, Clone)]
737#[serde(rename_all = "snake_case")]
738pub enum Modality {
739    Text,
740    Audio,
741}
742
743/// Parameters for audio output of a chat completion.
744#[derive(Serialize, Debug, Clone)]
745pub struct ChatCompletionAudioParam {
746    /// Specifies the output audio format. Must be one of `wav`, `aac`, `mp3`,
747    /// `flac`, `opus`, or `pcm16`.
748    pub format: AudioFormat,
749    /// The voice the model uses to respond.
750    pub voice: Voice,
751}
752
753/// The output audio format of a chat completion.
754#[derive(Serialize, Debug, Clone)]
755#[serde(rename_all = "snake_case")]
756pub enum AudioFormat {
757    Wav,
758    Aac,
759    Mp3,
760    Flac,
761    Opus,
762    Pcm16,
763}
764
765/// The voice the model uses to respond with audio output.
766#[derive(Serialize, Debug, Clone)]
767#[serde(untagged)]
768pub enum Voice {
769    /// A built-in voice name, e.g. `alloy`, `ash`, `ballad`, `coral`, `echo`,
770    /// `sage`, `shimmer`, or `verse`.
771    BuiltIn(String),
772    /// A custom voice reference, e.g. `{ "id": "voice_1234" }`.
773    Custom {
774        /// The custom voice ID, e.g. `voice_1234`.
775        id: String,
776    },
777}
778
779#[derive(Serialize, Debug, Clone)]
780pub struct ChatCompletionPredictionContentParam {
781    /// The content that should be matched when generating a model response. If
782    /// generated tokens would match this content, the entire model response can be
783    /// returned much more quickly.
784    pub content: ChatCompletionPredictionContentParamContent,
785
786    /// The type of the predicted content you want to provide.
787    /// This type is currently always `content`.
788    #[serde(rename = "type")]
789    pub type_: ChatCompletionPredictionContentParamType,
790}
791
792#[derive(Serialize, Debug, Clone)]
793#[serde(untagged)]
794pub enum ChatCompletionPredictionContentParamContent {
795    Text(String),
796    ChatCompletionContentPartTextParam {
797        /// The text content.
798        text: String,
799        /// The type of the content part.
800        #[serde(rename = "type")]
801        type_: ChatCompletionContentPartTextParamType,
802    },
803}
804
805#[derive(Serialize, Debug, Clone)]
806#[serde(rename_all = "snake_case")]
807pub enum ChatCompletionContentPartTextParamType {
808    Text,
809}
810
811#[derive(Serialize, Debug, Clone)]
812#[serde(rename_all = "snake_case")]
813pub enum ChatCompletionPredictionContentParamType {
814    Content,
815}
816
817/// DeepSeek: skip-serialization helper for the Beta `prefix` message field.
818#[cfg(feature = "deepseek")]
819#[inline]
820fn is_false(value: &bool) -> bool {
821    !value
822}
823
824#[derive(Serialize, Debug, Clone)]
825#[serde(untagged)]
826pub enum StopKeywords {
827    Word(String),
828    Words(Vec<String>),
829}
830
831#[derive(Serialize, Debug, Clone)]
832#[serde(rename_all = "snake_case")]
833pub enum LowMediumHighEnum {
834    Low,
835    Medium,
836    High,
837}
838
839#[derive(Serialize, Debug, Clone, Default)]
840pub struct WebSearchOptions {
841    /// High level guidance for the amount of context window space to use for the
842    /// search. One of `low`, `medium`, or `high`. `medium` is the default.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub search_context_size: Option<LowMediumHighEnum>,
845
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub user_location: Option<WebSearchOptionsUserLocation>,
848}
849
850#[derive(Serialize, Debug, Clone)]
851#[serde(tag = "type", rename_all = "snake_case")]
852pub enum WebSearchOptionsUserLocation {
853    /// The type of location approximation. Always `approximate`.
854    Approximate {
855        /// Approximate location parameters for the search.
856        approximate: WebSearchOptionsUserLocationApproximate,
857    },
858}
859
860#[derive(Serialize, Debug, Clone, Default)]
861pub struct WebSearchOptionsUserLocationApproximate {
862    /// Free text input for the city of the user, e.g. `San Francisco`.
863    #[serde(skip_serializing_if = "Option::is_none")]
864    pub city: Option<String>,
865
866    /// The two-letter [ISO country code](https://en.wikipedia.org/wiki/ISO_3166-1) of
867    /// the user, e.g. `US`.
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub country: Option<String>,
870
871    /// Free text input for the region of the user, e.g. `California`.
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub region: Option<String>,
874
875    /// The [IANA timezone](https://timeapi.io/documentation/iana-timezones) of the
876    /// user, e.g. `America/Los_Angeles`.
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub timezone: Option<String>,
879}
880
881#[derive(Serialize, Debug, Clone)]
882pub struct StreamOptions {
883    /// If set, an additional chunk will be streamed before the `data: [DONE]` message.
884    ///
885    /// The `usage` field on this chunk shows the token usage statistics for the entire
886    /// request, and the `choices` field will always be an empty array.
887    ///
888    /// All other chunks will also include a `usage` field, but with a null value.
889    /// **NOTE:** If the stream is interrupted, you may not receive the final usage
890    /// chunk which contains the total token usage for the request.
891    pub include_usage: bool,
892}
893
894#[derive(Serialize, Debug, Clone)]
895#[serde(tag = "type", rename_all = "snake_case")]
896pub enum RequestTool {
897    /// The type of the tool. Currently, only `function` is supported.
898    Function { function: ToolFunction },
899    /// The type of the custom tool. Always `custom`.
900    Custom {
901        /// Properties of the custom tool.
902        custom: ToolCustom,
903    },
904}
905
906#[derive(Serialize, Debug, Clone)]
907pub struct ToolFunction {
908    /// The name of the function to be called. Must be a-z, A-Z, 0-9, or
909    /// contain underscores and dashes, with a maximum length
910    /// of 64.
911    pub name: String,
912    /// A description of what the function does, used by the model to choose when and
913    /// how to call the function.
914    #[serde(skip_serializing_if = "Option::is_none")]
915    pub description: Option<String>,
916    /// The parameters the functions accepts, described as a JSON Schema object.
917    ///
918    /// See the
919    /// [openai function calling guide](https://platform.openai.com/docs/guides/function-calling)
920    /// for examples, and the
921    /// [JSON Schema reference](https://json-schema.org/understanding-json-schema/) for
922    /// documentation about the format.
923    ///
924    /// Omitting `parameters` defines a function with an empty parameter list.
925    #[serde(skip_serializing_if = "Option::is_none")]
926    pub parameters: Option<serde_json::Map<String, serde_json::Value>>,
927    /// Whether to enable strict schema adherence when generating the function call.
928    ///
929    /// If set to true, the model will follow the exact schema defined in the
930    /// `parameters` field. Only a subset of JSON Schema is supported when `strict` is
931    /// `true`. Learn more about Structured Outputs in the
932    /// [openai function calling guide](https://platform.openai.com/docs/guides/function-calling).
933    #[serde(skip_serializing_if = "Option::is_none")]
934    pub strict: Option<bool>,
935}
936
937#[derive(Serialize, Debug, Clone)]
938pub struct ToolCustom {
939    /// The name of the custom tool, used to identify it in tool calls.
940    pub name: String,
941    /// Optional description of the custom tool, used to provide more context.
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub description: Option<String>,
944    /// The input format for the custom tool. Default is unconstrained text.
945    #[serde(skip_serializing_if = "Option::is_none")]
946    pub format: Option<ToolCustomFormat>,
947}
948
949#[derive(Serialize, Debug, Clone)]
950#[serde(rename_all = "snake_case", tag = "type")]
951pub enum ToolCustomFormat {
952    /// Unconstrained text format. Always `text`.
953    Text,
954    /// Grammar format. Always `grammar`.
955    Grammar {
956        /// Your chosen grammar.
957        grammar: ToolCustomFormatGrammarGrammar,
958    },
959}
960
961#[derive(Debug, Serialize, Clone)]
962pub struct ToolCustomFormatGrammarGrammar {
963    /// The grammar definition.
964    pub definition: String,
965    /// The syntax of the grammar definition. One of `lark` or `regex`.
966    pub syntax: ToolCustomFormatGrammarGrammarSyntax,
967}
968
969#[derive(Debug, Serialize, Clone)]
970#[serde(rename_all = "snake_case")]
971pub enum ToolCustomFormatGrammarGrammarSyntax {
972    Lark,
973    Regex,
974}
975
976#[derive(Debug, Serialize, Clone)]
977#[serde(rename_all = "snake_case")]
978pub enum ToolChoice {
979    None,
980    Auto,
981    Required,
982    #[serde(untagged)]
983    Specific(ToolChoiceSpecific),
984}
985
986#[derive(Debug, Serialize, Clone)]
987#[serde(rename_all = "snake_case", tag = "type")]
988pub enum ToolChoiceSpecific {
989    /// Allowed tool configuration type. Always `allowed_tools`.
990    AllowedTools {
991        /// Constrains the tools available to the model to a pre-defined set.
992        allowed_tools: ToolChoiceAllowedTools,
993    },
994    /// For function calling, the type is always `function`.
995    Function { function: ToolChoiceFunction },
996    /// For custom tool calling, the type is always `custom`.
997    Custom { custom: ToolChoiceCustom },
998}
999
1000#[derive(Debug, Serialize, Clone)]
1001pub struct ToolChoiceAllowedTools {
1002    /// Constrains the tools available to the model to a pre-defined set.
1003    ///
1004    /// - `auto` allows the model to pick from among the allowed tools and generate a
1005    ///   message.
1006    /// - `required` requires the model to call one or more of the allowed tools.
1007    pub mode: ToolChoiceAllowedToolsMode,
1008    /// A list of tool definitions that the model should be allowed to call.
1009    ///
1010    /// For the Chat Completions API, the list of tool definitions might look like:
1011    ///
1012    /// ```json
1013    /// [
1014    ///   { "type": "function", "function": { "name": "get_weather" } },
1015    ///   { "type": "function", "function": { "name": "get_time" } }
1016    /// ]
1017    /// ```
1018    pub tools: Vec<serde_json::Map<String, serde_json::Value>>,
1019}
1020
1021/// The mode for allowed tools in tool choice.
1022///
1023/// Controls how the model should handle the set of allowed tools:
1024///
1025/// - `auto` allows the model to pick from among the allowed tools and generate a
1026///   message.
1027/// - `required` requires the model to call one or more of the allowed tools.
1028#[derive(Debug, Serialize, Clone)]
1029#[serde(rename_all = "lowercase")]
1030pub enum ToolChoiceAllowedToolsMode {
1031    /// The model can choose whether to use the allowed tools or not.
1032    Auto,
1033    /// The model must use at least one of the allowed tools.
1034    Required,
1035}
1036
1037#[derive(Debug, Serialize, Clone)]
1038pub struct ToolChoiceFunction {
1039    /// The name of the function to call.
1040    pub name: String,
1041}
1042
1043#[derive(Debug, Serialize, Clone)]
1044pub struct ToolChoiceCustom {
1045    /// The name of the custom tool to call.
1046    pub name: String,
1047}
1048
1049/// DeepSeek: controls the switch between thinking and non-thinking mode.
1050#[cfg(feature = "deepseek")]
1051#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
1052pub struct DeepSeekThinking {
1053    /// Whether to use thinking mode (`enabled`) or non-thinking mode
1054    /// (`disabled`). Defaults to `enabled`.
1055    #[serde(rename = "type")]
1056    pub type_: DeepSeekThinkingType,
1057}
1058
1059/// DeepSeek: whether thinking mode is enabled or disabled.
1060#[cfg(feature = "deepseek")]
1061#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
1062#[serde(rename_all = "lowercase")]
1063pub enum DeepSeekThinkingType {
1064    Enabled,
1065    Disabled,
1066}
1067
1068/// Constrains the effort on reasoning for reasoning models. This is an
1069/// official OpenAI parameter; reasoning providers such as DeepSeek and Qwen
1070/// accept a subset of these values and map the rest to their nearest effort
1071/// level.
1072#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
1073#[serde(rename_all = "lowercase")]
1074pub enum ReasoningEffort {
1075    None,
1076    Minimal,
1077    Low,
1078    Medium,
1079    High,
1080    Xhigh,
1081    Max,
1082}
1083
1084impl RequestBody {
1085    /// Whether this request asks for a streamed response. Defaults to
1086    /// `false` when [`RequestBody::stream`] is `None`.
1087    pub fn is_streaming(&self) -> bool {
1088        self.stream.unwrap_or(false)
1089    }
1090}
1091
1092impl Post for RequestBody {
1093    fn is_streaming(&self) -> bool {
1094        RequestBody::is_streaming(self)
1095    }
1096
1097    /// Builds the URL for the request.
1098    ///
1099    /// `base_url` should be like <https://api.openai.com/v1>
1100    fn build_url(&self, base_url: &str) -> Result<String, OapiError> {
1101        let mut url = Url::parse(base_url.trim_end_matches('/')).map_err(OapiError::UrlError)?;
1102        url.path_segments_mut()
1103            .map_err(|_| OapiError::UrlCannotBeBase(base_url.to_string()))?
1104            .push("chat")
1105            .push("completions");
1106
1107        Ok(url.to_string())
1108    }
1109}
1110
1111impl PostNoStream for RequestBody {
1112    type Response = super::response::no_streaming::ChatCompletion;
1113}
1114
1115impl PostStream for RequestBody {
1116    type Response = super::response::streaming::ChatCompletionChunk;
1117}
1118
1119#[cfg(test)]
1120mod request_test {
1121    use futures_util::StreamExt;
1122
1123    use super::*;
1124
1125    const DEEPSEEK_CHAT_URL: &str = "https://api.deepseek.com";
1126    const DEEPSEEK_MODEL: &str = "deepseek-v4-flash";
1127
1128    fn deepseek_api_key() -> Option<String> {
1129        std::env::var("DEEPSEEK_API_KEY")
1130            .ok()
1131            .map(|key| key.trim().to_string())
1132            .filter(|key| !key.is_empty())
1133    }
1134
1135    #[tokio::test]
1136    async fn test_deepseek_no_stream() {
1137        let Some(api_key) = deepseek_api_key() else {
1138            println!("Skipping: set DEEPSEEK_API_KEY to run this test");
1139            return;
1140        };
1141
1142        let request = RequestBody {
1143            messages: vec![
1144                Message::System {
1145                    content: "This is a request of test purpose. Reply briefly".into(),
1146                    name: None,
1147                },
1148                Message::User {
1149                    content: "What's your name?".into(),
1150                    name: None,
1151                },
1152            ],
1153            model: DEEPSEEK_MODEL.to_string(),
1154            stream: Some(false),
1155            ..Default::default()
1156        };
1157
1158        let response = request
1159            .get_response_string(
1160                &crate::rest::default_client(),
1161                DEEPSEEK_CHAT_URL,
1162                &crate::rest::RequestOptions::bearer(&api_key),
1163            )
1164            .await
1165            .unwrap();
1166
1167        println!("{}", response);
1168
1169        assert!(response.to_ascii_lowercase().contains("deepseek"));
1170    }
1171
1172    #[tokio::test]
1173    async fn test_deepseek_stream() {
1174        let Some(api_key) = deepseek_api_key() else {
1175            println!("Skipping: set DEEPSEEK_API_KEY to run this test");
1176            return;
1177        };
1178
1179        let request = RequestBody {
1180            messages: vec![
1181                Message::System {
1182                    content: "This is a request of test purpose. Reply briefly".into(),
1183                    name: None,
1184                },
1185                Message::User {
1186                    content: "Who are you?".into(),
1187                    name: None,
1188                },
1189            ],
1190            model: DEEPSEEK_MODEL.to_string(),
1191            stream: Some(true),
1192            ..Default::default()
1193        };
1194
1195        let mut response = request
1196            .get_stream_response_string(
1197                &crate::rest::default_client(),
1198                DEEPSEEK_CHAT_URL,
1199                &crate::rest::RequestOptions::bearer(&api_key),
1200            )
1201            .await
1202            .unwrap();
1203
1204        while let Some(chunk) = response.next().await {
1205            println!("{}", chunk.unwrap());
1206        }
1207    }
1208
1209    /// Assistant tool calls serialize with the official `type` tag
1210    /// (`{"type":"function",...}` / `{"type":"custom",...}`), not `role`.
1211    #[test]
1212    fn assistant_tool_call_serialization() {
1213        let function_call = AssistantToolCall::Function {
1214            id: "call_abc".to_string(),
1215            function: ToolCallFunction {
1216                arguments: "{\"city\":\"paris\"}".to_string(),
1217                name: "get_weather".to_string(),
1218            },
1219        };
1220        let json = serde_json::to_string(&function_call).unwrap();
1221        assert!(json.contains(r#""type":"function""#), "json: {json}");
1222        assert!(!json.contains(r#""role""#), "json: {json}");
1223
1224        let custom_call = AssistantToolCall::Custom {
1225            id: "call_def".to_string(),
1226            custom: ToolCallCustom {
1227                input: "2+2".to_string(),
1228                name: "calculator".to_string(),
1229            },
1230        };
1231        let json = serde_json::to_string(&custom_call).unwrap();
1232        assert!(json.contains(r#""type":"custom""#), "json: {json}");
1233        assert!(!json.contains(r#""role""#), "json: {json}");
1234    }
1235
1236    /// The `prediction` parameter sends its discriminator as `type`, not
1237    /// as the Rust field name `type_`.
1238    #[test]
1239    fn prediction_type_serialization() {
1240        let prediction = ChatCompletionPredictionContentParam {
1241            content: ChatCompletionPredictionContentParamContent::Text(
1242                "The capital of France is Paris.".to_string(),
1243            ),
1244            type_: ChatCompletionPredictionContentParamType::Content,
1245        };
1246        let json = serde_json::to_string(&prediction).unwrap();
1247        assert!(json.contains(r#""type":"content""#), "json: {json}");
1248        assert!(!json.contains("type_"), "json: {json}");
1249    }
1250
1251    /// `tool_choice: allowed_tools` sends `tools` as a JSON array of tool
1252    /// definitions, matching the official `Iterable[Dict[str, object]]`.
1253    #[test]
1254    fn allowed_tools_choice_serialization() {
1255        let mut weather = serde_json::Map::new();
1256        weather.insert("type".to_string(), serde_json::json!("function"));
1257        weather.insert(
1258            "function".to_string(),
1259            serde_json::json!({ "name": "get_weather" }),
1260        );
1261
1262        let choice = ToolChoiceSpecific::AllowedTools {
1263            allowed_tools: ToolChoiceAllowedTools {
1264                mode: ToolChoiceAllowedToolsMode::Required,
1265                tools: vec![weather],
1266            },
1267        };
1268        let json = serde_json::to_string(&choice).unwrap();
1269        assert!(json.contains(r#""type":"allowed_tools""#), "json: {json}");
1270        assert!(json.contains(r#""mode":"required""#), "json: {json}");
1271        // `tools` must serialize as an array, not an object.
1272        assert!(json.contains(r#""tools":[{"#), "json: {json}");
1273    }
1274
1275    /// `web_search_options` sends `search_context_size` as optional and the
1276    /// user location nested under an `approximate` key.
1277    #[test]
1278    fn web_search_options_serialization() {
1279        let options = WebSearchOptions {
1280            search_context_size: None,
1281            user_location: Some(WebSearchOptionsUserLocation::Approximate {
1282                approximate: WebSearchOptionsUserLocationApproximate {
1283                    city: Some("San Francisco".to_string()),
1284                    country: None,
1285                    region: None,
1286                    timezone: None,
1287                },
1288            }),
1289        };
1290        let json = serde_json::to_string(&options).unwrap();
1291        assert!(!json.contains("search_context_size"), "json: {json}");
1292        assert!(json.contains(r#""type":"approximate""#), "json: {json}");
1293        assert!(
1294            json.contains(r#""approximate":{"city":"San Francisco"}"#),
1295            "json: {json}"
1296        );
1297    }
1298
1299    /// `JSONSchema`/`ToolFunction` optional fields are omitted when unset.
1300    #[test]
1301    fn json_schema_optional_fields_serialization() {
1302        let schema = JSONSchema {
1303            name: "Answer".to_string(),
1304            description: None,
1305            schema: None,
1306            strict: None,
1307        };
1308        let json = serde_json::to_string(&schema).unwrap();
1309        assert_eq!(json, r#"{"name":"Answer"}"#);
1310
1311        let function = ToolFunction {
1312            name: "get_weather".to_string(),
1313            description: None,
1314            parameters: None,
1315            strict: None,
1316        };
1317        let json = serde_json::to_string(&function).unwrap();
1318        assert_eq!(json, r#"{"name":"get_weather"}"#);
1319    }
1320
1321    /// Plain-text user messages keep the official wire format: `content`
1322    /// is a JSON string, not a parts array.
1323    #[test]
1324    fn user_text_content_serialization() {
1325        let request = RequestBody {
1326            messages: vec![Message::User {
1327                content: "Hi".into(),
1328                name: None,
1329            }],
1330            model: "gpt-4o".to_string(),
1331            ..Default::default()
1332        };
1333
1334        let json = serde_json::to_string(&request).unwrap();
1335        assert!(json.contains(r#""content":"Hi""#), "json: {json}");
1336    }
1337
1338    /// System, developer and tool messages serialize `content` as a plain
1339    /// string by default and as a text-part array when parts are supplied
1340    /// (the official "string or array of content parts" shapes).
1341    #[test]
1342    fn system_developer_tool_content_serialization() {
1343        let request = RequestBody {
1344            messages: vec![
1345                Message::System {
1346                    content: "Be terse".into(),
1347                    name: None,
1348                },
1349                Message::Developer {
1350                    content: MessageContent::Parts(vec![ContentPart::Text {
1351                        text: "Prefer Rust".to_string(),
1352                        prompt_cache_breakpoint: None,
1353                    }]),
1354                    name: None,
1355                },
1356                Message::Tool {
1357                    content: MessageContent::Parts(vec![ContentPart::Text {
1358                        text: r#"{"temp": 21}"#.to_string(),
1359                        prompt_cache_breakpoint: None,
1360                    }]),
1361                    tool_call_id: "call_1".to_string(),
1362                },
1363            ],
1364            model: "gpt-4o".to_string(),
1365            ..Default::default()
1366        };
1367
1368        let json = serde_json::to_string(&request).unwrap();
1369        assert!(
1370            json.contains(r#""role":"system","content":"Be terse""#),
1371            "json: {json}"
1372        );
1373        assert!(
1374            json.contains(r#""role":"developer","content":[{"type":"text","text":"Prefer Rust"}]"#),
1375            "json: {json}"
1376        );
1377        assert!(
1378            json.contains(
1379                r#""role":"tool","content":[{"type":"text","text":"{\"temp\": 21}"}],"tool_call_id":"call_1""#
1380            ),
1381            "json: {json}"
1382        );
1383    }
1384
1385    /// Multimodal user messages serialize as content-part arrays with the
1386    /// official shapes, including `prompt_cache_breakpoint`.
1387    #[test]
1388    fn multimodal_content_serialization() {
1389        let request = RequestBody {
1390            messages: vec![Message::User {
1391                content: MessageContent::Parts(vec![
1392                    ContentPart::ImageUrl {
1393                        image_url: ContentPartImageUrl {
1394                            url: "https://example.com/cat.png".to_string(),
1395                            detail: Some(ImageDetail::High),
1396                        },
1397                        prompt_cache_breakpoint: None,
1398                    },
1399                    ContentPart::Text {
1400                        text: "What's in this image?".to_string(),
1401                        prompt_cache_breakpoint: Some(PromptCacheBreakpoint {
1402                            mode: PromptCacheBreakpointMode::Explicit,
1403                        }),
1404                    },
1405                ]),
1406                name: None,
1407            }],
1408            model: "gpt-4o".to_string(),
1409            ..Default::default()
1410        };
1411
1412        let json = serde_json::to_string(&request).unwrap();
1413        assert!(json.contains(r#""type":"image_url""#), "json: {json}");
1414        assert!(
1415            json.contains(r#""url":"https://example.com/cat.png""#),
1416            "json: {json}"
1417        );
1418        assert!(json.contains(r#""detail":"high""#), "json: {json}");
1419        assert!(json.contains(r#""type":"text""#), "json: {json}");
1420        assert!(
1421            json.contains(r#""prompt_cache_breakpoint":{"mode":"explicit"}"#),
1422            "json: {json}"
1423        );
1424    }
1425
1426    /// `input_audio` and `file` content parts serialize with the official
1427    /// shapes.
1428    #[test]
1429    fn audio_and_file_content_serialization() {
1430        let content = MessageContent::Parts(vec![
1431            ContentPart::InputAudio {
1432                input_audio: ContentPartInputAudio {
1433                    data: "aGVsbG8=".to_string(),
1434                    format: InputAudioFormat::Wav,
1435                },
1436                prompt_cache_breakpoint: None,
1437            },
1438            ContentPart::File {
1439                file: ContentPartFile {
1440                    file_id: Some("file-abc".to_string()),
1441                    ..Default::default()
1442                },
1443                prompt_cache_breakpoint: None,
1444            },
1445        ]);
1446
1447        let json = serde_json::to_string(&content).unwrap();
1448        assert!(json.contains(r#""type":"input_audio""#), "json: {json}");
1449        assert!(json.contains(r#""data":"aGVsbG8=""#), "json: {json}");
1450        assert!(json.contains(r#""format":"wav""#), "json: {json}");
1451        assert!(json.contains(r#""type":"file""#), "json: {json}");
1452        assert!(
1453            json.contains(r#""file":{"file_id":"file-abc"}"#),
1454            "json: {json}"
1455        );
1456        // Optional file fields are omitted when unset.
1457        assert!(!json.contains("file_data"), "json: {json}");
1458    }
1459
1460    /// `logit_bias`, `moderation` and `prompt_cache_options` serialize as
1461    /// the official request parameters (token-id keys as JSON strings).
1462    #[test]
1463    fn new_params_serialization() {
1464        let mut logit_bias = HashMap::new();
1465        logit_bias.insert(40u32, -100i32);
1466
1467        let request = RequestBody {
1468            messages: vec![Message::User {
1469                content: "Hi".into(),
1470                name: None,
1471            }],
1472            model: "gpt-5".to_string(),
1473            logit_bias: Some(logit_bias),
1474            moderation: Some(ChatModerationParam {
1475                model: "omni-moderation-latest".to_string(),
1476                policy: Some(ModerationPolicyParam {
1477                    input: Some(ModerationPolicySideParam {
1478                        mode: ModerationPolicyMode::Block,
1479                    }),
1480                    output: None,
1481                }),
1482            }),
1483            prompt_cache_options: Some(PromptCacheOptions {
1484                mode: Some(PromptCacheMode::Explicit),
1485                ttl: Some(PromptCacheTtl::ThirtyMinutes),
1486            }),
1487            ..Default::default()
1488        };
1489
1490        let json = serde_json::to_string(&request).unwrap();
1491        assert!(json.contains(r#""logit_bias":{"40":-100}"#), "json: {json}");
1492        assert!(
1493            json.contains(
1494                r#""moderation":{"model":"omni-moderation-latest","policy":{"input":{"mode":"block"}}}"#
1495            ),
1496            "json: {json}"
1497        );
1498        assert!(
1499            json.contains(r#""prompt_cache_options":{"mode":"explicit","ttl":"30m"}"#),
1500            "json: {json}"
1501        );
1502    }
1503
1504    /// Serializes the OpenAI `reasoning_effort` parameter.
1505    #[test]
1506    fn reasoning_effort_serialization() {
1507        let request = RequestBody {
1508            messages: vec![Message::User {
1509                content: "What's your name?".into(),
1510                name: None,
1511            }],
1512            model: "gpt-5".to_string(),
1513            reasoning_effort: Some(ReasoningEffort::Xhigh),
1514            ..Default::default()
1515        };
1516
1517        let json = serde_json::to_string(&request).unwrap();
1518        assert!(
1519            json.contains(r#""reasoning_effort":"xhigh""#),
1520            "json: {json}"
1521        );
1522    }
1523
1524    /// Serializes the DeepSeek Beta chat prefix completion fields.
1525    #[cfg(feature = "deepseek")]
1526    #[test]
1527    fn deepseek_assistant_prefix_serialization() {
1528        let request = RequestBody {
1529            messages: vec![
1530                Message::User {
1531                    content: "Please write quick sort code".into(),
1532                    name: None,
1533                },
1534                Message::Assistant {
1535                    content: Some("```python\n".to_string()),
1536                    audio: None,
1537                    refusal: None,
1538                    name: None,
1539                    prefix: true,
1540                    reasoning_content: None,
1541                    tool_calls: None,
1542                },
1543            ],
1544            model: DEEPSEEK_MODEL.to_string(),
1545            ..Default::default()
1546        };
1547
1548        let json = serde_json::to_string(&request).unwrap();
1549        assert!(json.contains(r#""prefix":true"#), "json: {json}");
1550    }
1551
1552    /// Serializes the DeepSeek `thinking`, `reasoning_effort` and `user_id`
1553    /// request parameters.
1554    #[cfg(feature = "deepseek")]
1555    #[test]
1556    fn deepseek_thinking_params_serialization() {
1557        let request = RequestBody {
1558            messages: vec![Message::User {
1559                content: "What's your name?".into(),
1560                name: None,
1561            }],
1562            model: DEEPSEEK_MODEL.to_string(),
1563            thinking: Some(DeepSeekThinking {
1564                type_: DeepSeekThinkingType::Disabled,
1565            }),
1566            user_id: Some("user-123".to_string()),
1567            ..Default::default()
1568        };
1569
1570        let json = serde_json::to_string(&request).unwrap();
1571        assert!(
1572            json.contains(r#""thinking":{"type":"disabled"}"#),
1573            "json: {json}"
1574        );
1575        assert!(json.contains(r#""user_id":"user-123""#), "json: {json}");
1576    }
1577
1578    /// Serializes the Qwen `enable_thinking`, `thinking_budget` and `top_k`
1579    /// request parameters.
1580    #[cfg(feature = "qwen")]
1581    #[test]
1582    fn qwen_params_serialization() {
1583        let request = RequestBody {
1584            messages: vec![Message::User {
1585                content: "What's your name?".into(),
1586                name: None,
1587            }],
1588            model: "qwen-plus".to_string(),
1589            enable_thinking: Some(false),
1590            thinking_budget: Some(1024),
1591            top_k: Some(20),
1592            ..Default::default()
1593        };
1594
1595        let json = serde_json::to_string(&request).unwrap();
1596        assert!(json.contains(r#""enable_thinking":false"#), "json: {json}");
1597        assert!(json.contains(r#""thinking_budget":1024"#), "json: {json}");
1598        assert!(json.contains(r#""top_k":20"#), "json: {json}");
1599    }
1600
1601    const QWEN_CHAT_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
1602    /// Qwen's multimodal flash model: accepts text, image and audio inputs
1603    /// through its OpenAI-compatible endpoint.
1604    const QWEN_MULTIMODAL_MODEL: &str = "qwen3.8-flash";
1605
1606    fn qwen_api_key() -> Option<String> {
1607        std::env::var("QWEN_API_KEY")
1608            .ok()
1609            .map(|key| key.trim().to_string())
1610            .filter(|key| !key.is_empty())
1611    }
1612
1613    /// Real request: a user message with an `image_url` content part. The
1614    /// image is the football sample used in Alibaba Cloud Model Studio's own
1615    /// documentation. Requires `QWEN_API_KEY`; skipped otherwise.
1616    #[tokio::test]
1617    async fn test_qwen_image_input() -> Result<(), anyhow::Error> {
1618        let Some(api_key) = qwen_api_key() else {
1619            println!("Skipping: set QWEN_API_KEY to run this test");
1620            return Ok(());
1621        };
1622
1623        let request = RequestBody {
1624            messages: vec![
1625                Message::System {
1626                    content: "This is a request of test purpose. Reply briefly".into(),
1627                    name: None,
1628                },
1629                Message::User {
1630                    content: MessageContent::Parts(vec![
1631                        ContentPart::ImageUrl {
1632                            image_url: ContentPartImageUrl {
1633                                url: "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/xzsgiz/football1.jpg"
1634                                    .to_string(),
1635                                detail: None,
1636                            },
1637                            prompt_cache_breakpoint: None,
1638                        },
1639                        ContentPart::Text {
1640                            text: "What is shown in this image? Answer with one short sentence."
1641                                .to_string(),
1642                            prompt_cache_breakpoint: None,
1643                        },
1644                    ]),
1645                    name: None,
1646                },
1647            ],
1648            model: QWEN_MULTIMODAL_MODEL.to_string(),
1649            ..Default::default()
1650        };
1651
1652        let response = request
1653            .get_response(
1654                &crate::rest::default_client(),
1655                QWEN_CHAT_URL,
1656                &crate::rest::RequestOptions::bearer(&api_key),
1657            )
1658            .await?;
1659
1660        let content = response.choices[0]
1661            .message
1662            .content
1663            .clone()
1664            .unwrap_or_default();
1665        println!("image response: {content}");
1666        assert!(
1667            !content.trim().is_empty(),
1668            "empty content for a valid image request"
1669        );
1670        Ok(())
1671    }
1672
1673    /// Real request: a user message with an `input_audio` content part
1674    /// carrying a public audio URL (the cherry sample from the Model Studio
1675    /// docs), answered by the streaming response. Requires `QWEN_API_KEY`;
1676    /// skipped otherwise.
1677    ///
1678    /// Uses `qwen-omni-turbo`: Qwen's Omni models are the multimodal class
1679    /// that accepts audio input on the OpenAI-compatible endpoint, and they
1680    /// require `stream: true`. (`qwen3.8-flash` rejects `input_audio` with a
1681    /// provider-side `400 incorrect modal 'audio'` error, verified with
1682    /// plain curl.)
1683    #[tokio::test]
1684    async fn test_qwen_audio_input() -> Result<(), anyhow::Error> {
1685        let Some(api_key) = qwen_api_key() else {
1686            println!("Skipping: set QWEN_API_KEY to run this test");
1687            return Ok(());
1688        };
1689
1690        let request = RequestBody {
1691            messages: vec![Message::User {
1692                content: MessageContent::Parts(vec![
1693                    ContentPart::InputAudio {
1694                        input_audio: ContentPartInputAudio {
1695                            data: "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250211/tixcef/cherry.wav"
1696                                .to_string(),
1697                            format: InputAudioFormat::Wav,
1698                        },
1699                        prompt_cache_breakpoint: None,
1700                    },
1701                    ContentPart::Text {
1702                        text: "What does the speaker say in this audio? Reply briefly."
1703                            .to_string(),
1704                        prompt_cache_breakpoint: None,
1705                    },
1706                ]),
1707                name: None,
1708            }],
1709            model: "qwen-omni-turbo".to_string(),
1710            stream: Some(true),
1711            modalities: Some(vec![Modality::Text]),
1712            ..Default::default()
1713        };
1714
1715        let mut stream = request
1716            .get_stream_response(
1717                &crate::rest::default_client(),
1718                QWEN_CHAT_URL,
1719                &crate::rest::RequestOptions::bearer(&api_key),
1720            )
1721            .await?;
1722
1723        let mut message = String::new();
1724        while let Some(chunk) = stream.next().await {
1725            let chunk = chunk?;
1726            if let Some(choice) = chunk.choices.first()
1727                && let Some(content) = choice.delta.content.as_deref()
1728            {
1729                message.push_str(content);
1730            }
1731        }
1732
1733        println!("audio response: {message}");
1734        assert!(
1735            !message.trim().is_empty(),
1736            "empty content for a valid audio request"
1737        );
1738        Ok(())
1739    }
1740
1741    /// Real request: a plain-text user message (the wire format of
1742    /// [`MessageContent::Text`]). Requires `QWEN_API_KEY`; skipped otherwise.
1743    #[tokio::test]
1744    async fn test_qwen_text_input() -> Result<(), anyhow::Error> {
1745        let Some(api_key) = qwen_api_key() else {
1746            println!("Skipping: set QWEN_API_KEY to run this test");
1747            return Ok(());
1748        };
1749
1750        let request = RequestBody {
1751            messages: vec![Message::User {
1752                content: "Reply with exactly one word.".into(),
1753                name: None,
1754            }],
1755            model: QWEN_MULTIMODAL_MODEL.to_string(),
1756            ..Default::default()
1757        };
1758
1759        let response = request
1760            .get_response(
1761                &crate::rest::default_client(),
1762                QWEN_CHAT_URL,
1763                &crate::rest::RequestOptions::bearer(&api_key),
1764            )
1765            .await?;
1766
1767        let content = response.choices[0]
1768            .message
1769            .content
1770            .clone()
1771            .unwrap_or_default();
1772        println!("text response: {content}");
1773        assert!(!content.trim().is_empty(), "empty content for text input");
1774        Ok(())
1775    }
1776
1777    /// Real request: streaming a multimodal (image + text) user message.
1778    /// Requires `QWEN_API_KEY`; skipped otherwise.
1779    #[tokio::test]
1780    async fn test_qwen_multimodal_stream() -> Result<(), anyhow::Error> {
1781        let Some(api_key) = qwen_api_key() else {
1782            println!("Skipping: set QWEN_API_KEY to run this test");
1783            return Ok(());
1784        };
1785
1786        let request = RequestBody {
1787            messages: vec![Message::User {
1788                content: MessageContent::Parts(vec![
1789                    ContentPart::ImageUrl {
1790                        image_url: ContentPartImageUrl {
1791                            url: "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/xzsgiz/football1.jpg"
1792                                .to_string(),
1793                            detail: None,
1794                        },
1795                        prompt_cache_breakpoint: None,
1796                    },
1797                    ContentPart::Text {
1798                        text: "What is shown in this image? Answer with one short sentence."
1799                            .to_string(),
1800                        prompt_cache_breakpoint: None,
1801                    },
1802                ]),
1803                name: None,
1804            }],
1805            model: QWEN_MULTIMODAL_MODEL.to_string(),
1806            stream: Some(true),
1807            ..Default::default()
1808        };
1809
1810        let mut stream = request
1811            .get_stream_response(
1812                &crate::rest::default_client(),
1813                QWEN_CHAT_URL,
1814                &crate::rest::RequestOptions::bearer(&api_key),
1815            )
1816            .await?;
1817
1818        let mut message = String::new();
1819        while let Some(chunk) = stream.next().await {
1820            let chunk = chunk?;
1821            if let Some(choice) = chunk.choices.first()
1822                && let Some(content) = choice.delta.content.as_deref()
1823            {
1824                message.push_str(content);
1825            }
1826        }
1827
1828        println!("streamed message: {message}");
1829        assert!(
1830            !message.trim().is_empty(),
1831            "empty streamed content for a valid image request"
1832        );
1833        Ok(())
1834    }
1835}