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