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