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    #[serde(skip_serializing_if = "Option::is_none")]
710    pub reasoning_content: Option<ReasoningContent>,
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub refusal: Option<String>,
713    #[serde(skip_serializing_if = "Option::is_none")]
714    pub name: Option<String>,
715    #[serde(skip_serializing_if = "Option::is_none")]
716    pub audio: Option<ChatCompletionRequestAssistantMessageAudio>,
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
719    #[deprecated]
720    #[serde(skip_serializing_if = "Option::is_none")]
721    pub function_call: Option<FunctionCall>,
722}
723
724/// Chat completion request message enum.
725///
726/// Redefined to use our extended `ChatCompletionRequestAssistantMessage`
727/// (with reasoning_content) and `ChatCompletionRequestUserMessage`
728/// (which references our extended content parts with video/audio).
729#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
730#[serde(tag = "role")]
731#[serde(rename_all = "lowercase")]
732pub enum ChatCompletionRequestMessage {
733    Developer(ChatCompletionRequestDeveloperMessage),
734    System(ChatCompletionRequestSystemMessage),
735    User(ChatCompletionRequestUserMessage),
736    Assistant(ChatCompletionRequestAssistantMessage),
737    Tool(ChatCompletionRequestToolMessage),
738    Function(ChatCompletionRequestFunctionMessage),
739}
740
741/// Backward-compatible name for the service tier reported in responses.
742pub type ServiceTierResponse = ServiceTier;
743
744/// Chat completion response message with multimodal content and reasoning.
745///
746/// Extends upstream `ChatCompletionResponseMessage` with:
747/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
748/// - `reasoning_content`: model reasoning output (DeepSeek-R1, QwQ)
749#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
750pub struct ChatCompletionResponseMessage {
751    /// Always serialized (as `null` when None) so clients can rely on the
752    /// `content` key being present alongside `reasoning_content` or
753    /// `tool_calls`. Matches the upstream OpenAI API shape (DGH-651).
754    pub content: Option<ChatCompletionMessageContent>,
755    #[serde(skip_serializing_if = "Option::is_none")]
756    pub refusal: Option<String>,
757    #[serde(skip_serializing_if = "Option::is_none")]
758    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
759    pub role: Role,
760    #[serde(skip_serializing_if = "Option::is_none")]
761    #[deprecated]
762    pub function_call: Option<FunctionCall>,
763    #[serde(skip_serializing_if = "Option::is_none")]
764    pub audio: Option<ChatCompletionResponseMessageAudio>,
765    /// Reasoning content produced by the model (DeepSeek-R1, QwQ).
766    pub reasoning_content: Option<String>,
767}
768
769/// Stream options with per-chunk usage reporting.
770///
771/// Extends upstream `ChatCompletionStreamOptions` with:
772/// - `continuous_usage_stats`: emit usage in every chunk, not just the final one
773#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
774pub struct ChatCompletionStreamOptions {
775    pub include_usage: bool,
776    /// When true, usage statistics are included in every streaming chunk.
777    /// Backends like vLLM/SGLang support this for real-time token counting.
778    #[serde(default)]
779    pub continuous_usage_stats: bool,
780}
781
782/// Chat completion request with multimodal processor support.
783///
784/// Extends upstream `CreateChatCompletionRequest` with:
785/// - `mm_processor_kwargs`: multimodal processor configuration (vLLM-specific)
786/// - Uses our extended `ChatCompletionRequestMessage` (with reasoning, video/audio)
787/// - Uses our extended `ChatCompletionStreamOptions` (with continuous_usage_stats)
788#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
789#[builder(name = "CreateChatCompletionRequestArgs")]
790#[builder(pattern = "mutable")]
791#[builder(setter(into, strip_option), default)]
792#[builder(derive(Debug))]
793#[builder(build_fn(error = "OpenAIError"))]
794pub struct CreateChatCompletionRequest {
795    pub messages: Vec<ChatCompletionRequestMessage>,
796    pub model: String,
797    /// Multimodal processor configuration (vLLM-specific)
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub mm_processor_kwargs: Option<serde_json::Value>,
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub store: Option<bool>,
802    #[serde(skip_serializing_if = "Option::is_none")]
803    pub reasoning_effort: Option<ReasoningEffort>,
804    #[serde(skip_serializing_if = "Option::is_none")]
805    pub metadata: Option<serde_json::Value>,
806    #[serde(skip_serializing_if = "Option::is_none")]
807    pub frequency_penalty: Option<f32>,
808    #[serde(skip_serializing_if = "Option::is_none")]
809    pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
810    #[serde(skip_serializing_if = "Option::is_none")]
811    pub logprobs: Option<bool>,
812    #[serde(skip_serializing_if = "Option::is_none")]
813    pub top_logprobs: Option<u8>,
814    #[deprecated]
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub max_tokens: Option<u32>,
817    #[serde(skip_serializing_if = "Option::is_none")]
818    pub max_completion_tokens: Option<u32>,
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub n: Option<u8>,
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
823    #[serde(skip_serializing_if = "Option::is_none")]
824    pub prediction: Option<PredictionContent>,
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub audio: Option<ChatCompletionAudio>,
827    #[serde(skip_serializing_if = "Option::is_none")]
828    pub presence_penalty: Option<f32>,
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub response_format: Option<ResponseFormat>,
831    #[serde(skip_serializing_if = "Option::is_none")]
832    pub seed: Option<i64>,
833    #[serde(skip_serializing_if = "Option::is_none")]
834    pub service_tier: Option<ServiceTier>,
835    #[serde(skip_serializing_if = "Option::is_none")]
836    pub stop: Option<Stop>,
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub stream: Option<bool>,
839    #[serde(skip_serializing_if = "Option::is_none")]
840    pub stream_options: Option<ChatCompletionStreamOptions>,
841    #[serde(skip_serializing_if = "Option::is_none")]
842    pub temperature: Option<f32>,
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub top_p: Option<f32>,
845    #[serde(skip_serializing_if = "Option::is_none")]
846    pub tools: Option<Vec<ChatCompletionTool>>,
847    #[serde(skip_serializing_if = "Option::is_none")]
848    pub tool_choice: Option<ChatCompletionToolChoiceOption>,
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub parallel_tool_calls: Option<bool>,
851    #[serde(skip_serializing_if = "Option::is_none")]
852    pub user: Option<String>,
853    #[deprecated]
854    #[serde(skip_serializing_if = "Option::is_none")]
855    pub function_call: Option<ChatCompletionFunctionCall>,
856    #[deprecated]
857    #[serde(skip_serializing_if = "Option::is_none")]
858    pub functions: Option<Vec<ChatCompletionFunctions>>,
859    #[serde(skip_serializing_if = "Option::is_none")]
860    pub web_search_options: Option<WebSearchOptions>,
861}
862
863/// Chat choice with extended response message.
864///
865/// Uses our `ChatCompletionResponseMessage` (multimodal content + reasoning).
866#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
867pub struct ChatChoice {
868    pub index: u32,
869    pub message: ChatCompletionResponseMessage,
870    pub finish_reason: Option<FinishReason>,
871    pub logprobs: Option<ChatChoiceLogprobs>,
872}
873
874/// Non-streaming chat completion response.
875#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
876pub struct CreateChatCompletionResponse {
877    pub id: String,
878    pub choices: Vec<ChatChoice>,
879    pub created: u32,
880    pub model: String,
881    pub service_tier: Option<ServiceTierResponse>,
882    pub system_fingerprint: Option<String>,
883    pub object: String,
884    pub usage: Option<CompletionUsage>,
885}
886
887pub type ChatCompletionResponseStream =
888    Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
889
890/// Streaming delta with reasoning content.
891///
892/// Extends upstream `ChatCompletionStreamResponseDelta` with:
893/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
894/// - `reasoning_content`: streaming reasoning tokens (DeepSeek-R1, QwQ)
895#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
896pub struct ChatCompletionStreamResponseDelta {
897    #[serde(skip_serializing_if = "Option::is_none")]
898    pub content: Option<ChatCompletionMessageContent>,
899    #[serde(skip_serializing_if = "Option::is_none")]
900    pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
901    #[serde(skip_serializing_if = "Option::is_none")]
902    pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub role: Option<Role>,
905    #[serde(skip_serializing_if = "Option::is_none")]
906    pub refusal: Option<String>,
907    /// Streaming reasoning content (DeepSeek-R1, QwQ models).
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub reasoning_content: Option<String>,
910}
911
912#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
913pub struct ChatCompletionStreamResponseDeltaFunctionCall {
914    pub name: Option<String>,
915    #[serde(default, deserialize_with = "deserialize_arguments_opt")]
916    pub arguments: Option<String>,
917}
918
919/// Streaming chat choice.
920#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
921pub struct ChatChoiceStream {
922    pub index: u32,
923    pub delta: ChatCompletionStreamResponseDelta,
924    pub finish_reason: Option<FinishReason>,
925    pub logprobs: Option<ChatChoiceLogprobs>,
926}
927
928/// Streaming chat completion response with extended choices.
929#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
930pub struct CreateChatCompletionStreamResponse {
931    pub id: String,
932    pub choices: Vec<ChatChoiceStream>,
933    pub created: u32,
934    pub model: String,
935    pub service_tier: Option<ServiceTierResponse>,
936    pub system_fingerprint: Option<String>,
937    pub object: String,
938    pub usage: Option<CompletionUsage>,
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944
945    #[test]
946    fn stop_accepts_token_id_array() {
947        let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
948
949        assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
950    }
951
952    #[test]
953    fn stop_accepts_string_and_string_array() {
954        let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
955
956        assert_eq!(stop, Stop::String(" The".to_string()));
957
958        let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
959
960        assert_eq!(
961            stop,
962            Stop::StringArray(vec!["A".to_string(), "B".to_string()])
963        );
964    }
965
966    #[test]
967    fn stop_token_id_display_string_remains_string_stop() {
968        let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
969
970        assert_eq!(stop, Stop::String("token_id:576".to_string()));
971
972        let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
973
974        assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
975    }
976
977    #[test]
978    fn stop_rejects_single_token_id() {
979        let result = serde_json::from_value::<Stop>(serde_json::json!(576));
980
981        assert!(result.is_err());
982    }
983
984    #[test]
985    fn stop_converts_from_upstream_stop_configuration() {
986        let upstream =
987            async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
988
989        assert_eq!(
990            Stop::from(upstream),
991            Stop::StringArray(vec!["END".to_string()])
992        );
993    }
994
995    #[test]
996    fn request_builder_accepts_upstream_reasoning_effort() {
997        let request = CreateChatCompletionRequestArgs::default()
998            .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
999            .build()
1000            .unwrap();
1001
1002        assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
1003    }
1004
1005    #[test]
1006    fn tool_call_defaults_type_on_deserialize() {
1007        let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1008            "id": "call_123",
1009            "function": {
1010                "name": "get_weather",
1011                "arguments": "{\"location\":\"SF\"}"
1012            }
1013        }))
1014        .unwrap();
1015
1016        assert_eq!(tool_call.r#type, FunctionType::Function);
1017    }
1018
1019    #[test]
1020    fn tool_call_serializes_type_for_wire_compat() {
1021        let tool_call = ChatCompletionMessageToolCall {
1022            id: "call_123".into(),
1023            r#type: FunctionType::Function,
1024            function: FunctionCall {
1025                name: "get_weather".into(),
1026                arguments: "{\"location\":\"SF\"}".into(),
1027            },
1028        };
1029
1030        let json = serde_json::to_value(tool_call).unwrap();
1031        assert_eq!(json["type"], "function");
1032    }
1033
1034    // -- dict-format arguments tests --
1035
1036    #[test]
1037    fn function_call_accepts_string_arguments() {
1038        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1039            "name": "get_weather",
1040            "arguments": "{\"location\":\"SF\"}"
1041        }))
1042        .unwrap();
1043        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1044    }
1045
1046    #[test]
1047    fn function_call_accepts_dict_arguments() {
1048        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1049            "name": "get_weather",
1050            "arguments": {"location": "SF"}
1051        }))
1052        .unwrap();
1053        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1054    }
1055
1056    #[test]
1057    fn function_call_rejects_integer_arguments() {
1058        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1059            "name": "f",
1060            "arguments": 42
1061        }));
1062        assert!(result.is_err());
1063    }
1064
1065    #[test]
1066    fn function_call_rejects_boolean_arguments() {
1067        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1068            "name": "f",
1069            "arguments": true
1070        }));
1071        assert!(result.is_err());
1072    }
1073
1074    #[test]
1075    fn function_call_rejects_null_arguments() {
1076        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1077            "name": "f",
1078            "arguments": null
1079        }));
1080        assert!(result.is_err());
1081    }
1082
1083    #[test]
1084    fn function_call_rejects_array_arguments() {
1085        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1086            "name": "f",
1087            "arguments": [1, 2, 3]
1088        }));
1089        assert!(result.is_err());
1090    }
1091
1092    #[test]
1093    fn function_call_stream_null_arguments_produces_none() {
1094        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1095            "name": "f",
1096            "arguments": null
1097        }))
1098        .unwrap();
1099        assert_eq!(fcs.arguments, None);
1100    }
1101
1102    #[test]
1103    fn function_call_stream_rejects_integer_arguments() {
1104        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1105            "name": "f",
1106            "arguments": 42
1107        }));
1108        assert!(result.is_err());
1109    }
1110
1111    #[test]
1112    fn function_call_stream_rejects_boolean_arguments() {
1113        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1114            "name": "f",
1115            "arguments": true
1116        }));
1117        assert!(result.is_err());
1118    }
1119
1120    #[test]
1121    fn function_call_stream_accepts_dict_arguments() {
1122        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1123            "name": "get_weather",
1124            "arguments": {"location": "SF"}
1125        }))
1126        .unwrap();
1127        assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1128    }
1129
1130    #[test]
1131    fn function_call_stream_accepts_null_arguments() {
1132        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1133            "name": "get_weather"
1134        }))
1135        .unwrap();
1136        assert_eq!(fcs.arguments, None);
1137    }
1138
1139    #[test]
1140    fn tool_call_with_dict_arguments_roundtrip() {
1141        let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1142            "id": "call_abc",
1143            "type": "function",
1144            "function": {
1145                "name": "search",
1146                "arguments": {"query": "hello", "limit": 10}
1147            }
1148        }))
1149        .unwrap();
1150        // Compare as parsed JSON values since key order is non-deterministic
1151        let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1152        assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1153        // Re-serialisation produces a string, not an object
1154        let json = serde_json::to_value(&tc).unwrap();
1155        assert!(json["function"]["arguments"].is_string());
1156    }
1157
1158    #[test]
1159    fn stream_delta_function_call_accepts_dict_arguments() {
1160        let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1161            serde_json::from_value(serde_json::json!({
1162                "name": "get_weather",
1163                "arguments": {"location": "SF"}
1164            }))
1165            .unwrap();
1166        assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1167    }
1168
1169    fn parse_content_part(json: serde_json::Value) -> ChatCompletionRequestUserMessageContentPart {
1170        serde_json::from_value(json).expect("content part deserialization failed")
1171    }
1172
1173    #[test]
1174    fn image_url_url_and_top_level_uuid() {
1175        let part = parse_content_part(serde_json::json!({
1176            "type": "image_url",
1177            "image_url": {"url": "https://x.example/y.png"},
1178            "uuid": "image-123"
1179        }));
1180
1181        match part {
1182            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1183                assert_eq!(part.uuid.as_deref(), Some("image-123"));
1184                assert_eq!(
1185                    part.image_url.as_ref().map(|image| image.url.as_str()),
1186                    Some("https://x.example/y.png")
1187                );
1188            }
1189            _ => panic!("expected image_url part"),
1190        }
1191    }
1192
1193    #[test]
1194    fn image_url_null_and_top_level_uuid() {
1195        let part = parse_content_part(serde_json::json!({
1196            "type": "image_url",
1197            "image_url": null,
1198            "uuid": "sku-1234-a"
1199        }));
1200
1201        match part {
1202            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1203                assert!(part.image_url.is_none());
1204                assert_eq!(part.uuid.as_deref(), Some("sku-1234-a"));
1205            }
1206            _ => panic!("expected image_url part"),
1207        }
1208    }
1209
1210    #[test]
1211    fn empty_media_urls_deserialize_as_uuid_only() {
1212        for (part_type, media_field, uuid) in [
1213            ("image_url", "image_url", "image-cache-key"),
1214            ("video_url", "video_url", "video-cache-key"),
1215            ("audio_url", "audio_url", "audio-cache-key"),
1216        ] {
1217            let part = parse_content_part(serde_json::json!({
1218                "type": part_type,
1219                (media_field): {"url": ""},
1220                "uuid": uuid
1221            }));
1222            let json = serde_json::to_value(part).unwrap();
1223
1224            assert!(json[media_field].is_null());
1225            assert_eq!(json["uuid"], uuid);
1226        }
1227    }
1228
1229    #[test]
1230    fn image_url_null_without_uuid_deserializes_for_use_site_validation() {
1231        let part = parse_content_part(serde_json::json!({
1232            "type": "image_url",
1233            "image_url": null
1234        }));
1235
1236        match part {
1237            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1238                assert!(part.image_url.is_none());
1239                assert!(part.uuid.is_none());
1240            }
1241            _ => panic!("expected image_url part"),
1242        }
1243    }
1244
1245    #[test]
1246    fn image_url_serialize_uuid_only_uses_null_image_url() {
1247        let part = ChatCompletionRequestMessageContentPartImage {
1248            image_url: None,
1249            uuid: Some("image-123".to_string()),
1250        };
1251        let json = serde_json::to_value(part).unwrap();
1252
1253        assert!(json["image_url"].is_null());
1254        assert_eq!(json["uuid"], "image-123");
1255    }
1256
1257    #[test]
1258    fn cached_media_builders_allow_omitting_urls() {
1259        let image = ChatCompletionRequestMessageContentPartImageArgs::default()
1260            .uuid("image-123")
1261            .build()
1262            .unwrap();
1263        let video = ChatCompletionRequestMessageContentPartVideoArgs::default()
1264            .uuid("video-123")
1265            .build()
1266            .unwrap();
1267        let audio = ChatCompletionRequestMessageContentPartAudioUrlArgs::default()
1268            .uuid("audio-123")
1269            .build()
1270            .unwrap();
1271
1272        let image_json = serde_json::to_value(image).unwrap();
1273        let video_json = serde_json::to_value(video).unwrap();
1274        let audio_json = serde_json::to_value(audio).unwrap();
1275        assert!(image_json["image_url"].is_null());
1276        assert!(video_json["video_url"].is_null());
1277        assert!(audio_json["audio_url"].is_null());
1278    }
1279
1280    #[test]
1281    fn image_url_uuid_accepts_opaque_string() {
1282        let part = parse_content_part(serde_json::json!({
1283            "type": "image_url",
1284            "image_url": {"url": "https://x.example/y.png"},
1285            "uuid": "img-ac3921de680bb217"
1286        }));
1287
1288        match part {
1289            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1290                assert_eq!(part.uuid.as_deref(), Some("img-ac3921de680bb217"));
1291            }
1292            _ => panic!("expected image_url part"),
1293        }
1294    }
1295
1296    #[test]
1297    fn url_conversions_preserve_required_urls() {
1298        let image: ImageUrl = "https://x.example/image.png".into();
1299        let video: VideoUrl = "https://x.example/video.mp4".into();
1300        let audio: AudioUrl = "https://x.example/audio.wav".into();
1301
1302        assert_eq!(image.url.as_str(), "https://x.example/image.png");
1303        assert_eq!(video.url.as_str(), "https://x.example/video.mp4");
1304        assert_eq!(audio.url.as_str(), "https://x.example/audio.wav");
1305    }
1306
1307    #[test]
1308    fn invalid_media_urls_remain_rejected() {
1309        for (part_type, media_field) in [
1310            ("image_url", "image_url"),
1311            ("video_url", "video_url"),
1312            ("audio_url", "audio_url"),
1313        ] {
1314            let result = serde_json::from_value::<ChatCompletionRequestUserMessageContentPart>(
1315                serde_json::json!({
1316                    "type": part_type,
1317                    (media_field): {"url": "not a url"},
1318                    "uuid": "cache-key"
1319                }),
1320            );
1321
1322            assert!(result.is_err(), "{part_type} accepted an invalid URL");
1323        }
1324    }
1325
1326    #[test]
1327    fn legacy_nested_media_uuids_remain_accepted() {
1328        let legacy_uuid = "92b888ad-e64a-478f-b688-5091e16544e3";
1329
1330        for (part_type, media_field, url) in [
1331            ("image_url", "image_url", "https://x.example/image.png"),
1332            ("video_url", "video_url", "https://x.example/video.mp4"),
1333            ("audio_url", "audio_url", "https://x.example/audio.wav"),
1334        ] {
1335            let part = parse_content_part(serde_json::json!({
1336                "type": part_type,
1337                (media_field): {"url": url, "uuid": legacy_uuid}
1338            }));
1339            let json = serde_json::to_value(part).unwrap();
1340
1341            assert_eq!(json[media_field]["url"], url);
1342            assert_eq!(json[media_field]["uuid"], legacy_uuid);
1343            assert!(json.get("uuid").is_none());
1344        }
1345    }
1346
1347    #[test]
1348    fn video_url_null_and_top_level_uuid() {
1349        let part = parse_content_part(serde_json::json!({
1350            "type": "video_url",
1351            "video_url": null,
1352            "uuid": "video-cache-key"
1353        }));
1354
1355        match part {
1356            ChatCompletionRequestUserMessageContentPart::VideoUrl(part) => {
1357                assert!(part.video_url.is_none());
1358                assert_eq!(part.uuid.as_deref(), Some("video-cache-key"));
1359            }
1360            _ => panic!("expected video_url part"),
1361        }
1362    }
1363
1364    #[test]
1365    fn audio_url_null_and_top_level_uuid() {
1366        let part = parse_content_part(serde_json::json!({
1367            "type": "audio_url",
1368            "audio_url": null,
1369            "uuid": "audio-cache-key"
1370        }));
1371
1372        match part {
1373            ChatCompletionRequestUserMessageContentPart::AudioUrl(part) => {
1374                assert!(part.audio_url.is_none());
1375                assert_eq!(part.uuid.as_deref(), Some("audio-cache-key"));
1376            }
1377            _ => panic!("expected audio_url part"),
1378        }
1379    }
1380
1381    #[test]
1382    fn message_content_array_preserves_uuid_alignment() {
1383        let payload = serde_json::json!({
1384            "role": "user",
1385            "content": [
1386                {"type": "text", "text": "describe these"},
1387                {
1388                    "type": "image_url",
1389                    "image_url": {"url": "https://x.example/img1.png"},
1390                    "uuid": "image-1"
1391                },
1392                {"type": "image_url", "image_url": null, "uuid": "image-1"}
1393            ]
1394        });
1395        let message: ChatCompletionRequestUserMessage = serde_json::from_value(payload).unwrap();
1396        let ChatCompletionRequestUserMessageContent::Array(parts) = message.content else {
1397            panic!("expected content array");
1398        };
1399
1400        assert_eq!(parts.len(), 3);
1401        match &parts[1] {
1402            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1403                assert!(
1404                    part.image_url
1405                        .as_ref()
1406                        .map(|image| image.url.as_str())
1407                        .is_some()
1408                );
1409                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1410            }
1411            _ => panic!("parts[1] should be image_url"),
1412        }
1413        match &parts[2] {
1414            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1415                assert!(part.image_url.is_none());
1416                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1417            }
1418            _ => panic!("parts[2] should be image_url"),
1419        }
1420    }
1421
1422    #[test]
1423    fn tool_message_accepts_media_content() {
1424        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
1425            "role": "tool",
1426            "tool_call_id": "call_media",
1427            "content": [
1428                {"type": "text", "text": "Screenshot captured"},
1429                {
1430                    "type": "image_url",
1431                    "image_url": {
1432                        "url": "data:image/png;base64,aGVsbG8="
1433                    }
1434                },
1435                {
1436                    "type": "video_url",
1437                    "video_url": {
1438                        "url": "https://example.com/clip.mp4"
1439                    }
1440                },
1441                {
1442                    "type": "audio_url",
1443                    "audio_url": {
1444                        "url": "https://example.com/audio.wav"
1445                    }
1446                }
1447            ]
1448        }))
1449        .unwrap();
1450
1451        let ChatCompletionRequestMessage::Tool(tool) = message else {
1452            panic!("expected tool message");
1453        };
1454        let ChatCompletionRequestToolMessageContent::Array(parts) = tool.content else {
1455            panic!("expected array content");
1456        };
1457        assert!(matches!(
1458            parts[1],
1459            ChatCompletionRequestToolMessageContentPart::ImageUrl(_)
1460        ));
1461        assert!(matches!(
1462            parts[2],
1463            ChatCompletionRequestToolMessageContentPart::VideoUrl(_)
1464        ));
1465        assert!(matches!(
1466            parts[3],
1467            ChatCompletionRequestToolMessageContentPart::AudioUrl(_)
1468        ));
1469    }
1470}