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