Skip to main content

dynamo_protocols/types/
chat.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Re-exports upstream async-openai chat types and defines inference-serving
5// extensions on top. Types prefixed with `Dynamo` or entirely absent from the
6// upstream spec are documented with the rationale for the extension.
7
8use std::pin::Pin;
9
10use derive_builder::Builder;
11use futures::Stream;
12use serde::{Deserialize, Serialize};
13use url::Url;
14use uuid::Uuid;
15
16use crate::error::OpenAIError;
17
18// ---------------------------------------------------------------------------
19// Re-exports from upstream async-openai (unchanged types)
20// ---------------------------------------------------------------------------
21// These types are structurally identical to the upstream definitions.
22// Consumers should use them via `dynamo_protocols::types::*` as before.
23
24pub use async_openai::types::chat::{
25    ChatChoiceLogprobs,
26    ChatCompletionAudio,
27    ChatCompletionAudioFormat,
28    ChatCompletionAudioVoice,
29    ChatCompletionFunctionCall,
30    ChatCompletionFunctions,
31    ChatCompletionFunctionsArgs,
32    ChatCompletionRequestAssistantMessageAudio,
33    ChatCompletionRequestAssistantMessageContent,
34    ChatCompletionRequestAssistantMessageContentPart,
35    ChatCompletionRequestDeveloperMessage,
36    ChatCompletionRequestDeveloperMessageArgs,
37    ChatCompletionRequestDeveloperMessageContent,
38    ChatCompletionRequestFunctionMessage,
39    ChatCompletionRequestFunctionMessageArgs,
40    ChatCompletionRequestMessageContentPartAudio,
41    ChatCompletionRequestMessageContentPartRefusal,
42    ChatCompletionRequestMessageContentPartText,
43    ChatCompletionRequestSystemMessage,
44    // Builder types (generated by derive_builder)
45    ChatCompletionRequestSystemMessageArgs,
46    ChatCompletionRequestSystemMessageContent,
47    ChatCompletionRequestSystemMessageContentPart,
48    ChatCompletionResponseMessageAudio,
49    ChatCompletionTokenLogprob,
50    Choice,
51    CompletionFinishReason,
52    CompletionTokensDetails,
53    CompletionUsage,
54    FunctionObject,
55    FunctionObjectArgs,
56    ImageDetail,
57    InputAudio,
58    InputAudioFormat,
59    Logprobs,
60    PredictionContent,
61    PredictionContentContent,
62    Prompt,
63    PromptTokensDetails,
64    ResponseFormat,
65    ResponseFormatJsonSchema,
66    Role,
67    ServiceTier,
68    TopLogprobs,
69    WebSearchContextSize,
70    WebSearchLocation,
71    WebSearchOptions,
72    WebSearchUserLocation,
73    WebSearchUserLocationType,
74};
75
76/// OpenAI stop configuration, with Dynamo's token-id stop extension.
77///
78/// The standard OpenAI shape accepts a string or string array. Dynamo also
79/// accepts an integer array, e.g. `"stop": [576]`, to express token-id stop
80/// conditions for tokenized in/out workflows. Strings like `"token_id:576"`
81/// remain ordinary string stops; the `token_id:<id>` format is only an output
82/// display format for logprobs.
83#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
84#[serde(untagged)]
85pub enum Stop {
86    String(String),
87    StringArray(Vec<String>),
88    TokenIdArray(Vec<u32>),
89}
90
91impl Stop {
92    pub fn strings(&self) -> Option<Vec<String>> {
93        match self {
94            Stop::String(s) => Some(vec![s.clone()]),
95            Stop::StringArray(arr) => Some(arr.clone()),
96            Stop::TokenIdArray(_) => None,
97        }
98    }
99
100    pub fn token_ids(&self) -> Option<Vec<u32>> {
101        match self {
102            Stop::TokenIdArray(arr) => Some(arr.clone()),
103            Stop::String(_) | Stop::StringArray(_) => None,
104        }
105    }
106}
107
108impl From<String> for Stop {
109    fn from(value: String) -> Self {
110        Stop::String(value)
111    }
112}
113
114impl From<&str> for Stop {
115    fn from(value: &str) -> Self {
116        Stop::String(value.to_string())
117    }
118}
119
120impl From<Vec<String>> for Stop {
121    fn from(value: Vec<String>) -> Self {
122        Stop::StringArray(value)
123    }
124}
125
126impl From<Vec<u32>> for Stop {
127    fn from(value: Vec<u32>) -> Self {
128        Stop::TokenIdArray(value)
129    }
130}
131
132impl From<async_openai::types::chat::StopConfiguration> for Stop {
133    fn from(value: async_openai::types::chat::StopConfiguration) -> Self {
134        match value {
135            async_openai::types::chat::StopConfiguration::String(value) => Stop::String(value),
136            async_openai::types::chat::StopConfiguration::StringArray(value) => {
137                Stop::StringArray(value)
138            }
139        }
140    }
141}
142
143// Upstream renamed FinishReason (streaming) -- re-export
144pub use async_openai::types::chat::FinishReason;
145
146// Upstream uses FunctionType where we used ChatCompletionToolType.
147// Re-export both names for compatibility.
148pub use async_openai::types::chat::FunctionType;
149
150/// Reasoning effort values accepted by OpenAI-compatible clients.
151///
152/// async-openai versions used by some Dynamo builds do not include `max`, but
153/// DeepSeek-V4 compatible clients may send it by default. Keep this local enum
154/// wire-compatible with upstream values and include `max`.
155#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "lowercase")]
157pub enum ReasoningEffort {
158    None,
159    Minimal,
160    Low,
161    Medium,
162    High,
163    Xhigh,
164    Max,
165}
166
167impl From<async_openai::types::chat::ReasoningEffort> for ReasoningEffort {
168    fn from(value: async_openai::types::chat::ReasoningEffort) -> Self {
169        match value {
170            async_openai::types::chat::ReasoningEffort::None => ReasoningEffort::None,
171            async_openai::types::chat::ReasoningEffort::Minimal => ReasoningEffort::Minimal,
172            async_openai::types::chat::ReasoningEffort::Low => ReasoningEffort::Low,
173            async_openai::types::chat::ReasoningEffort::Medium => ReasoningEffort::Medium,
174            async_openai::types::chat::ReasoningEffort::High => ReasoningEffort::High,
175            async_openai::types::chat::ReasoningEffort::Xhigh => ReasoningEffort::Xhigh,
176        }
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Flexible `arguments` deserialisation helpers
182// ---------------------------------------------------------------------------
183// Some agent frameworks (e.g. LangChain, custom harnesses) send tool-call
184// arguments as a pre-parsed JSON object instead of the canonical JSON
185// string.  The helpers below normalise both representations to a `String` so
186// downstream code never needs to branch on the wire format.
187
188fn deserialize_arguments<'de, D>(deserializer: D) -> Result<String, D::Error>
189where
190    D: serde::Deserializer<'de>,
191{
192    use serde::de::Error;
193    let value = serde_json::Value::deserialize(deserializer)?;
194    match value {
195        serde_json::Value::String(s) => Ok(s),
196        v @ serde_json::Value::Object(_) => {
197            // serde_json::to_string on a Value is infallible
198            Ok(serde_json::to_string(&v).unwrap())
199        }
200        other => Err(D::Error::custom(format!(
201            "expected string or object for `arguments`, got {other}"
202        ))),
203    }
204}
205
206fn deserialize_arguments_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
207where
208    D: serde::Deserializer<'de>,
209{
210    use serde::de::Error;
211    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
212    match value {
213        None => Ok(None),
214        Some(serde_json::Value::String(s)) => Ok(Some(s)),
215        Some(v @ serde_json::Value::Object(_)) => serde_json::to_string(&v)
216            .map(Some)
217            .map_err(|e| D::Error::custom(e.to_string())),
218        Some(other) => Err(D::Error::custom(format!(
219            "expected string or object for `arguments`, got {other}"
220        ))),
221    }
222}
223
224// ---------------------------------------------------------------------------
225// FunctionCall / FunctionCallStream — local definitions with flexible deser
226// ---------------------------------------------------------------------------
227// Upstream `async-openai` only accepts a JSON string for `arguments`.
228// We define these locally so we can attach `#[serde(deserialize_with)]` and
229// accept both string and object representations on the wire.
230
231/// The name and arguments of a function that should be called.
232///
233/// Accepts `arguments` as either a JSON string (`"{\"key\":\"value\"}"`) or a
234/// JSON object (`{"key": "value"}`); both are normalised to a JSON string
235/// on deserialisation so callers always see the canonical form.
236#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
237pub struct FunctionCall {
238    pub name: String,
239    #[serde(deserialize_with = "deserialize_arguments")]
240    pub arguments: String,
241}
242
243/// Streaming variant of [`FunctionCall`] where both fields are optional.
244#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
245pub struct FunctionCallStream {
246    pub name: Option<String>,
247    #[serde(default, deserialize_with = "deserialize_arguments_opt")]
248    pub arguments: Option<String>,
249}
250
251/// Streaming tool-call chunk.
252///
253/// Defined locally (instead of re-exporting from upstream) because its
254/// `function` field references our local [`FunctionCallStream`] with the
255/// flexible `arguments` deserialiser.
256#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
257pub struct ChatCompletionMessageToolCallChunk {
258    pub index: u32,
259    pub id: Option<String>,
260    pub r#type: Option<FunctionType>,
261    pub function: Option<FunctionCallStream>,
262}
263
264// ---------------------------------------------------------------------------
265// Types with structural differences from upstream (kept locally)
266// ---------------------------------------------------------------------------
267
268/// Image content part.
269///
270/// vLLM's OpenAI-compatible server accepts an optional top-level `uuid` on the
271/// media content part. For cache-hit-only requests, `image_url` is null and
272/// `uuid` carries the cache key. This is a vLLM extension, not part of the
273/// OpenAI Chat Completions API.
274#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
275#[builder(name = "ChatCompletionRequestMessageContentPartImageArgs")]
276#[builder(pattern = "mutable")]
277#[builder(setter(into, strip_option))]
278#[builder(derive(Debug))]
279#[builder(build_fn(error = "OpenAIError"))]
280pub struct ChatCompletionRequestMessageContentPartImage {
281    #[builder(default)]
282    #[serde(default)]
283    pub image_url: Option<ImageUrl>,
284    #[builder(default)]
285    #[serde(skip_serializing_if = "Option::is_none")]
286    /// vLLM-only multimodal processor-cache identity.
287    pub uuid: Option<String>,
288}
289
290/// Image URL with `url::Url` type and a legacy optional UUID.
291///
292/// New callers should put vLLM processor-cache identities on
293/// [`ChatCompletionRequestMessageContentPartImage::uuid`].
294#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
295#[builder(name = "ImageUrlArgs")]
296#[builder(pattern = "mutable")]
297#[builder(setter(into, strip_option))]
298#[builder(derive(Debug))]
299#[builder(build_fn(error = "OpenAIError"))]
300pub struct ImageUrl {
301    pub url: Url,
302    pub detail: Option<ImageDetail>,
303    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub uuid: Option<Uuid>,
306}
307
308/// Tool message content part with media observation support.
309///
310/// OpenAI's schema currently limits tool content parts to text, but
311/// OpenAI-compatible multimodal backends also accept image, video, and audio
312/// observations returned by tools.
313#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
314#[serde(tag = "type")]
315#[serde(rename_all = "snake_case")]
316pub enum ChatCompletionRequestToolMessageContentPart {
317    Text(ChatCompletionRequestMessageContentPartText),
318    ImageUrl(ChatCompletionRequestMessageContentPartImage),
319    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
320    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
321}
322
323/// Tool message content, extended to preserve media observations.
324#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
325#[serde(untagged)]
326pub enum ChatCompletionRequestToolMessageContent {
327    Text(String),
328    Array(Vec<ChatCompletionRequestToolMessageContentPart>),
329}
330
331impl Default for ChatCompletionRequestToolMessageContent {
332    fn default() -> Self {
333        Self::Text(String::new())
334    }
335}
336
337impl From<&str> for ChatCompletionRequestToolMessageContent {
338    fn from(value: &str) -> Self {
339        Self::Text(value.into())
340    }
341}
342
343impl From<String> for ChatCompletionRequestToolMessageContent {
344    fn from(value: String) -> Self {
345        Self::Text(value)
346    }
347}
348
349/// Tool message using Dynamo's media-capable content type.
350#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
351#[builder(name = "ChatCompletionRequestToolMessageArgs")]
352#[builder(pattern = "mutable")]
353#[builder(setter(into, strip_option), default)]
354#[builder(derive(Debug))]
355#[builder(build_fn(error = "OpenAIError"))]
356pub struct ChatCompletionRequestToolMessage {
357    pub content: ChatCompletionRequestToolMessageContent,
358    pub tool_call_id: String,
359}
360
361#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
362#[serde(rename_all = "lowercase")]
363pub enum ChatCompletionToolType {
364    #[default]
365    Function,
366}
367
368#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
369pub struct FunctionName {
370    pub name: String,
371}
372
373#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
374pub struct ChatCompletionNamedToolChoice {
375    pub r#type: ChatCompletionToolType,
376    pub function: FunctionName,
377}
378
379fn default_function_type() -> FunctionType {
380    FunctionType::Function
381}
382
383/// Tool call kept locally to preserve `type: "function"` in unary request/response payloads.
384///
385/// Differs from upstream: `type` is serialized by default and also defaults to
386/// `function` when omitted during deserialization, preserving compatibility with
387/// both Dynamo's historical wire format and upstream spec-compliant inputs.
388#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
389pub struct ChatCompletionMessageToolCall {
390    pub id: String,
391    #[serde(default = "default_function_type")]
392    pub r#type: FunctionType,
393    pub function: FunctionCall,
394}
395
396/// Tool choice enum kept locally because upstream changed variant names.
397#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
398#[serde(rename_all = "lowercase")]
399pub enum ChatCompletionToolChoiceOption {
400    #[default]
401    None,
402    Auto,
403    Required,
404    #[serde(untagged)]
405    Named(ChatCompletionNamedToolChoice),
406}
407
408#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
409#[builder(name = "ChatCompletionToolArgs")]
410#[builder(pattern = "mutable")]
411#[builder(setter(into, strip_option), default)]
412#[builder(derive(Debug))]
413#[builder(build_fn(error = "OpenAIError"))]
414pub struct ChatCompletionTool {
415    #[builder(default = "ChatCompletionToolType::Function")]
416    pub r#type: ChatCompletionToolType,
417    pub function: FunctionObject,
418}
419
420// ---------------------------------------------------------------------------
421// Inference-serving extensions (not in upstream)
422// ---------------------------------------------------------------------------
423
424/// Matched stop condition from the backend.
425///
426/// Inference backends (vLLM, SGLang) report which stop condition triggered:
427/// - `String`: a matched user-provided stop sequence
428/// - `Int`: a matched stop token ID
429/// - `IntArray`: matched stop token IDs reported as a sequence
430#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
431#[serde(untagged)]
432pub enum StopReason {
433    String(String),
434    Int(i64),
435    IntArray(Vec<i64>),
436}
437
438/// Reasoning content from a previous assistant turn.
439///
440/// Deserializes from either:
441/// - A plain string: `"reasoning_content": "thinking..."` -> `Text("thinking...")`
442/// - An array of strings: `"reasoning_content": ["seg1", "seg2"]` -> `Segments(["seg1", "seg2"])`
443///
444/// The `Segments` variant preserves interleaved reasoning order needed for KV cache-correct
445/// context reconstruction. `segments[i]` is the reasoning that preceded `tool_calls[i]`;
446/// `segments[tool_calls.len()]` is any trailing reasoning after the last tool call.
447#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
448#[serde(untagged)]
449pub enum ReasoningContent {
450    /// Flat string -- single reasoning block or legacy backward-compat form.
451    Text(String),
452    /// Interleaved segments. segments[i] precedes tool_calls[i];
453    /// segments[N] is trailing reasoning after the last tool call.
454    Segments(Vec<String>),
455}
456
457impl ReasoningContent {
458    /// Join all segments (or return text as-is) into a single flat string.
459    pub fn to_flat_string(&self) -> String {
460        match self {
461            ReasoningContent::Text(s) => s.clone(),
462            ReasoningContent::Segments(segs) => segs
463                .iter()
464                .filter(|s| !s.is_empty())
465                .cloned()
466                .collect::<Vec<_>>()
467                .join("\n"),
468        }
469    }
470
471    /// Returns the segments if this is the `Segments` variant, `None` for `Text`.
472    pub fn segments(&self) -> Option<&[String]> {
473        match self {
474            ReasoningContent::Segments(segs) => Some(segs),
475            ReasoningContent::Text(_) => None,
476        }
477    }
478}
479
480// -- Multimodal content types for responses (not in upstream) --
481
482/// Response content part for text in assistant messages
483#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
484pub struct ChatCompletionResponseContentPartText {
485    pub text: String,
486}
487
488/// Response content part for image URLs in assistant messages
489#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
490pub struct ChatCompletionResponseContentPartImageUrl {
491    pub image_url: ImageUrlResponse,
492}
493
494/// Response content part for video URLs in assistant messages
495#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
496pub struct ChatCompletionResponseContentPartVideoUrl {
497    pub video_url: VideoUrlResponse,
498}
499
500/// Response content part for audio URLs in assistant messages
501#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
502pub struct ChatCompletionResponseContentPartAudioUrl {
503    pub audio_url: AudioUrlResponse,
504}
505
506#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
507pub struct ImageUrlResponse {
508    pub url: String,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub detail: Option<String>,
511}
512
513#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
514pub struct VideoUrlResponse {
515    pub url: String,
516}
517
518#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
519pub struct AudioUrlResponse {
520    pub url: String,
521}
522
523/// Content parts for assistant responses supporting multiple modalities
524#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
525#[serde(tag = "type", rename_all = "snake_case")]
526pub enum ChatCompletionResponseContentPart {
527    Text(ChatCompletionResponseContentPartText),
528    ImageUrl(ChatCompletionResponseContentPartImageUrl),
529    VideoUrl(ChatCompletionResponseContentPartVideoUrl),
530    AudioUrl(ChatCompletionResponseContentPartAudioUrl),
531}
532
533/// Assistant message content -- can be a simple string or multimodal content parts.
534///
535/// Upstream uses `Option<String>` for the content field. We extend this to
536/// support multimodal responses (text + images + video + audio) from backends
537/// like vLLM that can return non-text content.
538#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
539#[serde(untagged)]
540pub enum ChatCompletionMessageContent {
541    /// Simple text content (backward compatible)
542    Text(String),
543    /// Array of content parts (for multimodal responses)
544    Parts(Vec<ChatCompletionResponseContentPart>),
545}
546
547// -- Multimodal input types (video/audio URL support, not in upstream) --
548
549#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
550#[builder(name = "VideoUrlArgs")]
551#[builder(pattern = "mutable")]
552#[builder(setter(into, strip_option))]
553#[builder(derive(Debug))]
554#[builder(build_fn(error = "OpenAIError"))]
555pub struct VideoUrl {
556    pub url: Url,
557    pub detail: Option<ImageDetail>,
558    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
559    #[serde(skip_serializing_if = "Option::is_none")]
560    pub uuid: Option<Uuid>,
561}
562
563#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
564#[builder(name = "ChatCompletionRequestMessageContentPartVideoArgs")]
565#[builder(pattern = "mutable")]
566#[builder(setter(into, strip_option))]
567#[builder(derive(Debug))]
568#[builder(build_fn(error = "OpenAIError"))]
569pub struct ChatCompletionRequestMessageContentPartVideo {
570    #[builder(default)]
571    #[serde(default)]
572    pub video_url: Option<VideoUrl>,
573    #[builder(default)]
574    #[serde(skip_serializing_if = "Option::is_none")]
575    /// vLLM-only multimodal processor-cache identity.
576    pub uuid: Option<String>,
577}
578
579#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
580#[builder(name = "AudioUrlArgs")]
581#[builder(pattern = "mutable")]
582#[builder(setter(into, strip_option))]
583#[builder(derive(Debug))]
584#[builder(build_fn(error = "OpenAIError"))]
585pub struct AudioUrl {
586    pub url: Url,
587    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub uuid: Option<Uuid>,
590}
591
592#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
593#[builder(name = "ChatCompletionRequestMessageContentPartAudioUrlArgs")]
594#[builder(pattern = "mutable")]
595#[builder(setter(into, strip_option))]
596#[builder(derive(Debug))]
597#[builder(build_fn(error = "OpenAIError"))]
598pub struct ChatCompletionRequestMessageContentPartAudioUrl {
599    #[builder(default)]
600    #[serde(default)]
601    pub audio_url: Option<AudioUrl>,
602    #[builder(default)]
603    #[serde(skip_serializing_if = "Option::is_none")]
604    /// vLLM-only multimodal processor-cache identity.
605    pub uuid: Option<String>,
606}
607
608// -- Extended request/response types --
609
610/// User message content -- references our extended content part enum.
611#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
612#[serde(untagged)]
613pub enum ChatCompletionRequestUserMessageContent {
614    Text(String),
615    Array(Vec<ChatCompletionRequestUserMessageContentPart>),
616}
617
618#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
619#[builder(name = "ChatCompletionRequestUserMessageArgs")]
620#[builder(pattern = "mutable")]
621#[builder(setter(into, strip_option), default)]
622#[builder(derive(Debug))]
623#[builder(build_fn(error = "OpenAIError"))]
624pub struct ChatCompletionRequestUserMessage {
625    pub content: ChatCompletionRequestUserMessageContent,
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub name: Option<String>,
628}
629
630impl Default for ChatCompletionRequestUserMessageContent {
631    fn default() -> Self {
632        Self::Text(String::new())
633    }
634}
635
636impl From<&str> for ChatCompletionRequestUserMessageContent {
637    fn from(value: &str) -> Self {
638        Self::Text(value.into())
639    }
640}
641
642impl From<String> for ChatCompletionRequestUserMessageContent {
643    fn from(value: String) -> Self {
644        Self::Text(value)
645    }
646}
647
648impl From<Vec<ChatCompletionRequestUserMessageContentPart>>
649    for ChatCompletionRequestUserMessageContent
650{
651    fn from(value: Vec<ChatCompletionRequestUserMessageContentPart>) -> Self {
652        Self::Array(value)
653    }
654}
655
656/// User message content part with video and audio URL support.
657///
658/// Extends upstream `ChatCompletionRequestUserMessageContentPart` with:
659/// - `VideoUrl`: video input for multimodal models
660/// - `AudioUrl`: audio URL input (distinct from base64 InputAudio)
661#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
662#[serde(tag = "type")]
663#[serde(rename_all = "snake_case")]
664pub enum ChatCompletionRequestUserMessageContentPart {
665    Text(ChatCompletionRequestMessageContentPartText),
666    ImageUrl(ChatCompletionRequestMessageContentPartImage),
667    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
668    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
669    InputAudio(ChatCompletionRequestMessageContentPartAudio),
670}
671
672/// Assistant message with reasoning content support.
673///
674/// Extends upstream `ChatCompletionRequestAssistantMessage` with:
675/// - `reasoning_content`: interleaved reasoning segments for KV cache correctness
676///   (DeepSeek-R1, QwQ models)
677#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
678#[builder(name = "ChatCompletionRequestAssistantMessageArgs")]
679#[builder(pattern = "mutable")]
680#[builder(setter(into, strip_option), default)]
681#[builder(derive(Debug))]
682#[builder(build_fn(error = "OpenAIError"))]
683pub struct ChatCompletionRequestAssistantMessage {
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub content: Option<ChatCompletionRequestAssistantMessageContent>,
686    /// Reasoning content from a previous assistant turn.
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub reasoning_content: Option<ReasoningContent>,
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub refusal: Option<String>,
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub name: Option<String>,
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub audio: Option<ChatCompletionRequestAssistantMessageAudio>,
695    #[serde(skip_serializing_if = "Option::is_none")]
696    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
697    #[deprecated]
698    #[serde(skip_serializing_if = "Option::is_none")]
699    pub function_call: Option<FunctionCall>,
700}
701
702/// Chat completion request message enum.
703///
704/// Redefined to use our extended `ChatCompletionRequestAssistantMessage`
705/// (with reasoning_content) and `ChatCompletionRequestUserMessage`
706/// (which references our extended content parts with video/audio).
707#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
708#[serde(tag = "role")]
709#[serde(rename_all = "lowercase")]
710pub enum ChatCompletionRequestMessage {
711    Developer(ChatCompletionRequestDeveloperMessage),
712    System(ChatCompletionRequestSystemMessage),
713    User(ChatCompletionRequestUserMessage),
714    Assistant(ChatCompletionRequestAssistantMessage),
715    Tool(ChatCompletionRequestToolMessage),
716    Function(ChatCompletionRequestFunctionMessage),
717}
718
719/// Backward-compatible name for the service tier reported in responses.
720pub type ServiceTierResponse = ServiceTier;
721
722/// Chat completion response message with multimodal content and reasoning.
723///
724/// Extends upstream `ChatCompletionResponseMessage` with:
725/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
726/// - `reasoning_content`: model reasoning output (DeepSeek-R1, QwQ)
727#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
728pub struct ChatCompletionResponseMessage {
729    /// Always serialized (as `null` when None) so clients can rely on the
730    /// `content` key being present alongside `reasoning_content` or
731    /// `tool_calls`. Matches the upstream OpenAI API shape (DGH-651).
732    pub content: Option<ChatCompletionMessageContent>,
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub refusal: Option<String>,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
737    pub role: Role,
738    #[serde(skip_serializing_if = "Option::is_none")]
739    #[deprecated]
740    pub function_call: Option<FunctionCall>,
741    #[serde(skip_serializing_if = "Option::is_none")]
742    pub audio: Option<ChatCompletionResponseMessageAudio>,
743    /// Reasoning content produced by the model (DeepSeek-R1, QwQ).
744    pub reasoning_content: Option<String>,
745}
746
747/// Stream options with per-chunk usage reporting.
748///
749/// Extends upstream `ChatCompletionStreamOptions` with:
750/// - `continuous_usage_stats`: emit usage in every chunk, not just the final one
751#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
752pub struct ChatCompletionStreamOptions {
753    pub include_usage: bool,
754    /// When true, usage statistics are included in every streaming chunk.
755    /// Backends like vLLM/SGLang support this for real-time token counting.
756    #[serde(default)]
757    pub continuous_usage_stats: bool,
758}
759
760/// Chat completion request with multimodal processor support.
761///
762/// Extends upstream `CreateChatCompletionRequest` with:
763/// - `mm_processor_kwargs`: multimodal processor configuration (vLLM-specific)
764/// - Uses our extended `ChatCompletionRequestMessage` (with reasoning, video/audio)
765/// - Uses our extended `ChatCompletionStreamOptions` (with continuous_usage_stats)
766#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
767#[builder(name = "CreateChatCompletionRequestArgs")]
768#[builder(pattern = "mutable")]
769#[builder(setter(into, strip_option), default)]
770#[builder(derive(Debug))]
771#[builder(build_fn(error = "OpenAIError"))]
772pub struct CreateChatCompletionRequest {
773    pub messages: Vec<ChatCompletionRequestMessage>,
774    pub model: String,
775    /// Multimodal processor configuration (vLLM-specific)
776    #[serde(skip_serializing_if = "Option::is_none")]
777    pub mm_processor_kwargs: Option<serde_json::Value>,
778    #[serde(skip_serializing_if = "Option::is_none")]
779    pub store: Option<bool>,
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub reasoning_effort: Option<ReasoningEffort>,
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub metadata: Option<serde_json::Value>,
784    #[serde(skip_serializing_if = "Option::is_none")]
785    pub frequency_penalty: Option<f32>,
786    #[serde(skip_serializing_if = "Option::is_none")]
787    pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub logprobs: Option<bool>,
790    #[serde(skip_serializing_if = "Option::is_none")]
791    pub top_logprobs: Option<u8>,
792    #[deprecated]
793    #[serde(skip_serializing_if = "Option::is_none")]
794    pub max_tokens: Option<u32>,
795    #[serde(skip_serializing_if = "Option::is_none")]
796    pub max_completion_tokens: Option<u32>,
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub n: Option<u8>,
799    #[serde(skip_serializing_if = "Option::is_none")]
800    pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
801    #[serde(skip_serializing_if = "Option::is_none")]
802    pub prediction: Option<PredictionContent>,
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub audio: Option<ChatCompletionAudio>,
805    #[serde(skip_serializing_if = "Option::is_none")]
806    pub presence_penalty: Option<f32>,
807    #[serde(skip_serializing_if = "Option::is_none")]
808    pub response_format: Option<ResponseFormat>,
809    #[serde(skip_serializing_if = "Option::is_none")]
810    pub seed: Option<i64>,
811    #[serde(skip_serializing_if = "Option::is_none")]
812    pub service_tier: Option<ServiceTier>,
813    #[serde(skip_serializing_if = "Option::is_none")]
814    pub stop: Option<Stop>,
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub stream: Option<bool>,
817    #[serde(skip_serializing_if = "Option::is_none")]
818    pub stream_options: Option<ChatCompletionStreamOptions>,
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub temperature: Option<f32>,
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub top_p: Option<f32>,
823    #[serde(skip_serializing_if = "Option::is_none")]
824    pub tools: Option<Vec<ChatCompletionTool>>,
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub tool_choice: Option<ChatCompletionToolChoiceOption>,
827    #[serde(skip_serializing_if = "Option::is_none")]
828    pub parallel_tool_calls: Option<bool>,
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub user: Option<String>,
831    #[deprecated]
832    #[serde(skip_serializing_if = "Option::is_none")]
833    pub function_call: Option<ChatCompletionFunctionCall>,
834    #[deprecated]
835    #[serde(skip_serializing_if = "Option::is_none")]
836    pub functions: Option<Vec<ChatCompletionFunctions>>,
837    #[serde(skip_serializing_if = "Option::is_none")]
838    pub web_search_options: Option<WebSearchOptions>,
839}
840
841/// Chat choice with extended response message.
842///
843/// Uses our `ChatCompletionResponseMessage` (multimodal content + reasoning).
844#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
845pub struct ChatChoice {
846    pub index: u32,
847    pub message: ChatCompletionResponseMessage,
848    pub finish_reason: Option<FinishReason>,
849    pub logprobs: Option<ChatChoiceLogprobs>,
850}
851
852/// Non-streaming chat completion response.
853#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
854pub struct CreateChatCompletionResponse {
855    pub id: String,
856    pub choices: Vec<ChatChoice>,
857    pub created: u32,
858    pub model: String,
859    pub service_tier: Option<ServiceTierResponse>,
860    pub system_fingerprint: Option<String>,
861    pub object: String,
862    pub usage: Option<CompletionUsage>,
863}
864
865pub type ChatCompletionResponseStream =
866    Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
867
868/// Streaming delta with reasoning content.
869///
870/// Extends upstream `ChatCompletionStreamResponseDelta` with:
871/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
872/// - `reasoning_content`: streaming reasoning tokens (DeepSeek-R1, QwQ)
873#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
874pub struct ChatCompletionStreamResponseDelta {
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub content: Option<ChatCompletionMessageContent>,
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub role: Option<Role>,
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub refusal: Option<String>,
885    /// Streaming reasoning content (DeepSeek-R1, QwQ models).
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub reasoning_content: Option<String>,
888}
889
890#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
891pub struct ChatCompletionStreamResponseDeltaFunctionCall {
892    pub name: Option<String>,
893    #[serde(default, deserialize_with = "deserialize_arguments_opt")]
894    pub arguments: Option<String>,
895}
896
897/// Streaming chat choice.
898#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
899pub struct ChatChoiceStream {
900    pub index: u32,
901    pub delta: ChatCompletionStreamResponseDelta,
902    pub finish_reason: Option<FinishReason>,
903    pub logprobs: Option<ChatChoiceLogprobs>,
904}
905
906/// Streaming chat completion response with extended choices.
907#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
908pub struct CreateChatCompletionStreamResponse {
909    pub id: String,
910    pub choices: Vec<ChatChoiceStream>,
911    pub created: u32,
912    pub model: String,
913    pub service_tier: Option<ServiceTierResponse>,
914    pub system_fingerprint: Option<String>,
915    pub object: String,
916    pub usage: Option<CompletionUsage>,
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    #[test]
924    fn stop_accepts_token_id_array() {
925        let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
926
927        assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
928    }
929
930    #[test]
931    fn stop_accepts_string_and_string_array() {
932        let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
933
934        assert_eq!(stop, Stop::String(" The".to_string()));
935
936        let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
937
938        assert_eq!(
939            stop,
940            Stop::StringArray(vec!["A".to_string(), "B".to_string()])
941        );
942    }
943
944    #[test]
945    fn stop_token_id_display_string_remains_string_stop() {
946        let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
947
948        assert_eq!(stop, Stop::String("token_id:576".to_string()));
949
950        let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
951
952        assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
953    }
954
955    #[test]
956    fn stop_rejects_single_token_id() {
957        let result = serde_json::from_value::<Stop>(serde_json::json!(576));
958
959        assert!(result.is_err());
960    }
961
962    #[test]
963    fn stop_converts_from_upstream_stop_configuration() {
964        let upstream =
965            async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
966
967        assert_eq!(
968            Stop::from(upstream),
969            Stop::StringArray(vec!["END".to_string()])
970        );
971    }
972
973    #[test]
974    fn request_builder_accepts_upstream_reasoning_effort() {
975        let request = CreateChatCompletionRequestArgs::default()
976            .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
977            .build()
978            .unwrap();
979
980        assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
981    }
982
983    #[test]
984    fn tool_call_defaults_type_on_deserialize() {
985        let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
986            "id": "call_123",
987            "function": {
988                "name": "get_weather",
989                "arguments": "{\"location\":\"SF\"}"
990            }
991        }))
992        .unwrap();
993
994        assert_eq!(tool_call.r#type, FunctionType::Function);
995    }
996
997    #[test]
998    fn tool_call_serializes_type_for_wire_compat() {
999        let tool_call = ChatCompletionMessageToolCall {
1000            id: "call_123".into(),
1001            r#type: FunctionType::Function,
1002            function: FunctionCall {
1003                name: "get_weather".into(),
1004                arguments: "{\"location\":\"SF\"}".into(),
1005            },
1006        };
1007
1008        let json = serde_json::to_value(tool_call).unwrap();
1009        assert_eq!(json["type"], "function");
1010    }
1011
1012    // -- dict-format arguments tests --
1013
1014    #[test]
1015    fn function_call_accepts_string_arguments() {
1016        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1017            "name": "get_weather",
1018            "arguments": "{\"location\":\"SF\"}"
1019        }))
1020        .unwrap();
1021        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1022    }
1023
1024    #[test]
1025    fn function_call_accepts_dict_arguments() {
1026        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1027            "name": "get_weather",
1028            "arguments": {"location": "SF"}
1029        }))
1030        .unwrap();
1031        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1032    }
1033
1034    #[test]
1035    fn function_call_rejects_integer_arguments() {
1036        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1037            "name": "f",
1038            "arguments": 42
1039        }));
1040        assert!(result.is_err());
1041    }
1042
1043    #[test]
1044    fn function_call_rejects_boolean_arguments() {
1045        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1046            "name": "f",
1047            "arguments": true
1048        }));
1049        assert!(result.is_err());
1050    }
1051
1052    #[test]
1053    fn function_call_rejects_null_arguments() {
1054        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1055            "name": "f",
1056            "arguments": null
1057        }));
1058        assert!(result.is_err());
1059    }
1060
1061    #[test]
1062    fn function_call_rejects_array_arguments() {
1063        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1064            "name": "f",
1065            "arguments": [1, 2, 3]
1066        }));
1067        assert!(result.is_err());
1068    }
1069
1070    #[test]
1071    fn function_call_stream_null_arguments_produces_none() {
1072        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1073            "name": "f",
1074            "arguments": null
1075        }))
1076        .unwrap();
1077        assert_eq!(fcs.arguments, None);
1078    }
1079
1080    #[test]
1081    fn function_call_stream_rejects_integer_arguments() {
1082        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1083            "name": "f",
1084            "arguments": 42
1085        }));
1086        assert!(result.is_err());
1087    }
1088
1089    #[test]
1090    fn function_call_stream_rejects_boolean_arguments() {
1091        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1092            "name": "f",
1093            "arguments": true
1094        }));
1095        assert!(result.is_err());
1096    }
1097
1098    #[test]
1099    fn function_call_stream_accepts_dict_arguments() {
1100        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1101            "name": "get_weather",
1102            "arguments": {"location": "SF"}
1103        }))
1104        .unwrap();
1105        assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1106    }
1107
1108    #[test]
1109    fn function_call_stream_accepts_null_arguments() {
1110        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1111            "name": "get_weather"
1112        }))
1113        .unwrap();
1114        assert_eq!(fcs.arguments, None);
1115    }
1116
1117    #[test]
1118    fn tool_call_with_dict_arguments_roundtrip() {
1119        let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1120            "id": "call_abc",
1121            "type": "function",
1122            "function": {
1123                "name": "search",
1124                "arguments": {"query": "hello", "limit": 10}
1125            }
1126        }))
1127        .unwrap();
1128        // Compare as parsed JSON values since key order is non-deterministic
1129        let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1130        assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1131        // Re-serialisation produces a string, not an object
1132        let json = serde_json::to_value(&tc).unwrap();
1133        assert!(json["function"]["arguments"].is_string());
1134    }
1135
1136    #[test]
1137    fn stream_delta_function_call_accepts_dict_arguments() {
1138        let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1139            serde_json::from_value(serde_json::json!({
1140                "name": "get_weather",
1141                "arguments": {"location": "SF"}
1142            }))
1143            .unwrap();
1144        assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1145    }
1146
1147    fn parse_content_part(json: serde_json::Value) -> ChatCompletionRequestUserMessageContentPart {
1148        serde_json::from_value(json).expect("content part deserialization failed")
1149    }
1150
1151    #[test]
1152    fn image_url_url_and_top_level_uuid() {
1153        let part = parse_content_part(serde_json::json!({
1154            "type": "image_url",
1155            "image_url": {"url": "https://x.example/y.png"},
1156            "uuid": "image-123"
1157        }));
1158
1159        match part {
1160            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1161                assert_eq!(part.uuid.as_deref(), Some("image-123"));
1162                assert_eq!(
1163                    part.image_url.as_ref().map(|image| image.url.as_str()),
1164                    Some("https://x.example/y.png")
1165                );
1166            }
1167            _ => panic!("expected image_url part"),
1168        }
1169    }
1170
1171    #[test]
1172    fn image_url_null_and_top_level_uuid() {
1173        let part = parse_content_part(serde_json::json!({
1174            "type": "image_url",
1175            "image_url": null,
1176            "uuid": "sku-1234-a"
1177        }));
1178
1179        match part {
1180            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1181                assert!(part.image_url.is_none());
1182                assert_eq!(part.uuid.as_deref(), Some("sku-1234-a"));
1183            }
1184            _ => panic!("expected image_url part"),
1185        }
1186    }
1187
1188    #[test]
1189    fn image_url_null_without_uuid_deserializes_for_use_site_validation() {
1190        let part = parse_content_part(serde_json::json!({
1191            "type": "image_url",
1192            "image_url": null
1193        }));
1194
1195        match part {
1196            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1197                assert!(part.image_url.is_none());
1198                assert!(part.uuid.is_none());
1199            }
1200            _ => panic!("expected image_url part"),
1201        }
1202    }
1203
1204    #[test]
1205    fn image_url_serialize_uuid_only_uses_null_image_url() {
1206        let part = ChatCompletionRequestMessageContentPartImage {
1207            image_url: None,
1208            uuid: Some("image-123".to_string()),
1209        };
1210        let json = serde_json::to_value(part).unwrap();
1211
1212        assert!(json["image_url"].is_null());
1213        assert_eq!(json["uuid"], "image-123");
1214    }
1215
1216    #[test]
1217    fn cached_media_builders_allow_omitting_urls() {
1218        let image = ChatCompletionRequestMessageContentPartImageArgs::default()
1219            .uuid("image-123")
1220            .build()
1221            .unwrap();
1222        let video = ChatCompletionRequestMessageContentPartVideoArgs::default()
1223            .uuid("video-123")
1224            .build()
1225            .unwrap();
1226        let audio = ChatCompletionRequestMessageContentPartAudioUrlArgs::default()
1227            .uuid("audio-123")
1228            .build()
1229            .unwrap();
1230
1231        let image_json = serde_json::to_value(image).unwrap();
1232        let video_json = serde_json::to_value(video).unwrap();
1233        let audio_json = serde_json::to_value(audio).unwrap();
1234        assert!(image_json["image_url"].is_null());
1235        assert!(video_json["video_url"].is_null());
1236        assert!(audio_json["audio_url"].is_null());
1237    }
1238
1239    #[test]
1240    fn image_url_uuid_accepts_opaque_string() {
1241        let part = parse_content_part(serde_json::json!({
1242            "type": "image_url",
1243            "image_url": {"url": "https://x.example/y.png"},
1244            "uuid": "img-ac3921de680bb217"
1245        }));
1246
1247        match part {
1248            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1249                assert_eq!(part.uuid.as_deref(), Some("img-ac3921de680bb217"));
1250            }
1251            _ => panic!("expected image_url part"),
1252        }
1253    }
1254
1255    #[test]
1256    fn url_conversions_preserve_required_urls() {
1257        let image: ImageUrl = "https://x.example/image.png".into();
1258        let video: VideoUrl = "https://x.example/video.mp4".into();
1259        let audio: AudioUrl = "https://x.example/audio.wav".into();
1260
1261        assert_eq!(image.url.as_str(), "https://x.example/image.png");
1262        assert_eq!(video.url.as_str(), "https://x.example/video.mp4");
1263        assert_eq!(audio.url.as_str(), "https://x.example/audio.wav");
1264    }
1265
1266    #[test]
1267    fn legacy_nested_media_uuids_remain_accepted() {
1268        let legacy_uuid = "92b888ad-e64a-478f-b688-5091e16544e3";
1269
1270        for (part_type, media_field, url) in [
1271            ("image_url", "image_url", "https://x.example/image.png"),
1272            ("video_url", "video_url", "https://x.example/video.mp4"),
1273            ("audio_url", "audio_url", "https://x.example/audio.wav"),
1274        ] {
1275            let part = parse_content_part(serde_json::json!({
1276                "type": part_type,
1277                (media_field): {"url": url, "uuid": legacy_uuid}
1278            }));
1279            let json = serde_json::to_value(part).unwrap();
1280
1281            assert_eq!(json[media_field]["url"], url);
1282            assert_eq!(json[media_field]["uuid"], legacy_uuid);
1283            assert!(json.get("uuid").is_none());
1284        }
1285    }
1286
1287    #[test]
1288    fn video_url_null_and_top_level_uuid() {
1289        let part = parse_content_part(serde_json::json!({
1290            "type": "video_url",
1291            "video_url": null,
1292            "uuid": "video-cache-key"
1293        }));
1294
1295        match part {
1296            ChatCompletionRequestUserMessageContentPart::VideoUrl(part) => {
1297                assert!(part.video_url.is_none());
1298                assert_eq!(part.uuid.as_deref(), Some("video-cache-key"));
1299            }
1300            _ => panic!("expected video_url part"),
1301        }
1302    }
1303
1304    #[test]
1305    fn audio_url_null_and_top_level_uuid() {
1306        let part = parse_content_part(serde_json::json!({
1307            "type": "audio_url",
1308            "audio_url": null,
1309            "uuid": "audio-cache-key"
1310        }));
1311
1312        match part {
1313            ChatCompletionRequestUserMessageContentPart::AudioUrl(part) => {
1314                assert!(part.audio_url.is_none());
1315                assert_eq!(part.uuid.as_deref(), Some("audio-cache-key"));
1316            }
1317            _ => panic!("expected audio_url part"),
1318        }
1319    }
1320
1321    #[test]
1322    fn message_content_array_preserves_uuid_alignment() {
1323        let payload = serde_json::json!({
1324            "role": "user",
1325            "content": [
1326                {"type": "text", "text": "describe these"},
1327                {
1328                    "type": "image_url",
1329                    "image_url": {"url": "https://x.example/img1.png"},
1330                    "uuid": "image-1"
1331                },
1332                {"type": "image_url", "image_url": null, "uuid": "image-1"}
1333            ]
1334        });
1335        let message: ChatCompletionRequestUserMessage = serde_json::from_value(payload).unwrap();
1336        let ChatCompletionRequestUserMessageContent::Array(parts) = message.content else {
1337            panic!("expected content array");
1338        };
1339
1340        assert_eq!(parts.len(), 3);
1341        match &parts[1] {
1342            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1343                assert!(
1344                    part.image_url
1345                        .as_ref()
1346                        .map(|image| image.url.as_str())
1347                        .is_some()
1348                );
1349                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1350            }
1351            _ => panic!("parts[1] should be image_url"),
1352        }
1353        match &parts[2] {
1354            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1355                assert!(part.image_url.is_none());
1356                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1357            }
1358            _ => panic!("parts[2] should be image_url"),
1359        }
1360    }
1361
1362    #[test]
1363    fn tool_message_accepts_media_content() {
1364        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
1365            "role": "tool",
1366            "tool_call_id": "call_media",
1367            "content": [
1368                {"type": "text", "text": "Screenshot captured"},
1369                {
1370                    "type": "image_url",
1371                    "image_url": {
1372                        "url": "data:image/png;base64,aGVsbG8="
1373                    }
1374                },
1375                {
1376                    "type": "video_url",
1377                    "video_url": {
1378                        "url": "https://example.com/clip.mp4"
1379                    }
1380                },
1381                {
1382                    "type": "audio_url",
1383                    "audio_url": {
1384                        "url": "https://example.com/audio.wav"
1385                    }
1386                }
1387            ]
1388        }))
1389        .unwrap();
1390
1391        let ChatCompletionRequestMessage::Tool(tool) = message else {
1392            panic!("expected tool message");
1393        };
1394        let ChatCompletionRequestToolMessageContent::Array(parts) = tool.content else {
1395            panic!("expected array content");
1396        };
1397        assert!(matches!(
1398            parts[1],
1399            ChatCompletionRequestToolMessageContentPart::ImageUrl(_)
1400        ));
1401        assert!(matches!(
1402            parts[2],
1403            ChatCompletionRequestToolMessageContentPart::VideoUrl(_)
1404        ));
1405        assert!(matches!(
1406            parts[3],
1407            ChatCompletionRequestToolMessageContentPart::AudioUrl(_)
1408        ));
1409    }
1410}