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