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