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