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