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