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