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