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/// Continuation chunks carry only `arguments`; `name` is omitted rather
263/// than serialized as `null`, matching OpenAI output.
264#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
265pub struct FunctionCallStream {
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub name: Option<String>,
268    #[serde(
269        default,
270        skip_serializing_if = "Option::is_none",
271        deserialize_with = "deserialize_arguments_opt"
272    )]
273    pub arguments: Option<String>,
274}
275
276/// Streaming tool-call chunk.
277///
278/// Defined locally (instead of re-exporting from upstream) because its
279/// `function` field references our local [`FunctionCallStream`] with the
280/// flexible `arguments` deserialiser.
281#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
282pub struct ChatCompletionMessageToolCallChunk {
283    pub index: u32,
284    /// Only `index` is required by the spec; `id`, `type`, and `function`
285    /// are omitted on continuation chunks, matching OpenAI output.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub id: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub r#type: Option<FunctionType>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub function: Option<FunctionCallStream>,
292}
293
294// ---------------------------------------------------------------------------
295// Types with structural differences from upstream (kept locally)
296// ---------------------------------------------------------------------------
297
298/// Image content part.
299///
300/// vLLM's OpenAI-compatible server accepts an optional top-level `uuid` on the
301/// media content part. For cache-hit-only requests, `uuid` carries the cache
302/// key and the canonical `image_url` is null. Clients constrained by vLLM's
303/// request schema may instead send `{"url": ""}`, which deserializes to the
304/// same representation. This is a vLLM extension, not part of the OpenAI Chat
305/// Completions API.
306#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
307#[builder(name = "ChatCompletionRequestMessageContentPartImageArgs")]
308#[builder(pattern = "mutable")]
309#[builder(setter(into, strip_option))]
310#[builder(derive(Debug))]
311#[builder(build_fn(error = "OpenAIError"))]
312pub struct ChatCompletionRequestMessageContentPartImage {
313    #[builder(default)]
314    #[serde(default, deserialize_with = "deserialize_optional_media")]
315    pub image_url: Option<ImageUrl>,
316    #[builder(default)]
317    #[serde(skip_serializing_if = "Option::is_none")]
318    /// vLLM-only multimodal processor-cache identity.
319    pub uuid: Option<String>,
320}
321
322/// Image URL with `url::Url` type and a legacy optional UUID.
323///
324/// New callers should put vLLM processor-cache identities on
325/// [`ChatCompletionRequestMessageContentPartImage::uuid`].
326#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
327#[builder(name = "ImageUrlArgs")]
328#[builder(pattern = "mutable")]
329#[builder(setter(into, strip_option))]
330#[builder(derive(Debug))]
331#[builder(build_fn(error = "OpenAIError"))]
332pub struct ImageUrl {
333    pub url: Url,
334    pub detail: Option<ImageDetail>,
335    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub uuid: Option<Uuid>,
338}
339
340/// Tool message content part with media observation support.
341///
342/// OpenAI's schema currently limits tool content parts to text, but
343/// OpenAI-compatible multimodal backends also accept image, video, and audio
344/// observations returned by tools.
345#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
346#[serde(tag = "type")]
347#[serde(rename_all = "snake_case")]
348pub enum ChatCompletionRequestToolMessageContentPart {
349    Text(ChatCompletionRequestMessageContentPartText),
350    ImageUrl(ChatCompletionRequestMessageContentPartImage),
351    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
352    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
353}
354
355/// Tool message content, extended to preserve media observations.
356#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
357#[serde(untagged)]
358pub enum ChatCompletionRequestToolMessageContent {
359    Text(String),
360    Array(Vec<ChatCompletionRequestToolMessageContentPart>),
361}
362
363impl Default for ChatCompletionRequestToolMessageContent {
364    fn default() -> Self {
365        Self::Text(String::new())
366    }
367}
368
369impl From<&str> for ChatCompletionRequestToolMessageContent {
370    fn from(value: &str) -> Self {
371        Self::Text(value.into())
372    }
373}
374
375impl From<String> for ChatCompletionRequestToolMessageContent {
376    fn from(value: String) -> Self {
377        Self::Text(value)
378    }
379}
380
381/// Tool message using Dynamo's media-capable content type.
382#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
383#[builder(name = "ChatCompletionRequestToolMessageArgs")]
384#[builder(pattern = "mutable")]
385#[builder(setter(into, strip_option), default)]
386#[builder(derive(Debug))]
387#[builder(build_fn(error = "OpenAIError"))]
388pub struct ChatCompletionRequestToolMessage {
389    pub content: ChatCompletionRequestToolMessageContent,
390    pub tool_call_id: String,
391}
392
393#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
394pub struct ChatChoiceLogprobs {
395    pub content: Option<Vec<ChatCompletionTokenLogprob>>,
396    pub refusal: Option<Vec<ChatCompletionTokenLogprob>>,
397}
398
399/// Token logprob entry with optional backend token ID.
400///
401/// Some inference backends can report both the rendered token string and its
402/// vocabulary ID. Keeping this optional preserves the upstream OpenAI shape
403/// when token IDs are unavailable.
404#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
405pub struct ChatCompletionTokenLogprob {
406    pub token: String,
407    pub logprob: f32,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub token_id: Option<u32>,
410    pub bytes: Option<Vec<u8>>,
411    pub top_logprobs: Vec<TopLogprobs>,
412}
413
414#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
415#[serde(rename_all = "lowercase")]
416pub enum ChatCompletionToolType {
417    #[default]
418    Function,
419}
420
421#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
422pub struct FunctionName {
423    pub name: String,
424}
425
426#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
427pub struct ChatCompletionNamedToolChoice {
428    pub r#type: ChatCompletionToolType,
429    pub function: FunctionName,
430}
431
432fn default_function_type() -> FunctionType {
433    FunctionType::Function
434}
435
436/// Tool call kept locally to preserve `type: "function"` in unary request/response payloads.
437///
438/// Differs from upstream: `type` is serialized by default and also defaults to
439/// `function` when omitted during deserialization, preserving compatibility with
440/// both Dynamo's historical wire format and upstream spec-compliant inputs.
441#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
442pub struct ChatCompletionMessageToolCall {
443    pub id: String,
444    #[serde(default = "default_function_type")]
445    pub r#type: FunctionType,
446    pub function: FunctionCall,
447}
448
449/// Tool choice enum kept locally because upstream changed variant names.
450#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
451#[serde(rename_all = "lowercase")]
452pub enum ChatCompletionToolChoiceOption {
453    #[default]
454    None,
455    Auto,
456    Required,
457    #[serde(untagged)]
458    Named(ChatCompletionNamedToolChoice),
459}
460
461#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
462#[builder(name = "ChatCompletionToolArgs")]
463#[builder(pattern = "mutable")]
464#[builder(setter(into, strip_option), default)]
465#[builder(derive(Debug))]
466#[builder(build_fn(error = "OpenAIError"))]
467pub struct ChatCompletionTool {
468    #[builder(default = "ChatCompletionToolType::Function")]
469    pub r#type: ChatCompletionToolType,
470    pub function: FunctionObject,
471}
472
473// ---------------------------------------------------------------------------
474// Inference-serving extensions (not in upstream)
475// ---------------------------------------------------------------------------
476
477/// Matched stop condition from the backend.
478///
479/// Inference backends (vLLM, SGLang) report which stop condition triggered:
480/// - `String`: a matched user-provided stop sequence
481/// - `Int`: a matched stop token ID
482/// - `IntArray`: matched stop token IDs reported as a sequence
483#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
484#[serde(untagged)]
485pub enum StopReason {
486    String(String),
487    Int(i64),
488    IntArray(Vec<i64>),
489}
490
491/// Reasoning content from a previous assistant turn.
492///
493/// Deserializes from either:
494/// - A plain string: `"reasoning_content": "thinking..."` -> `Text("thinking...")`
495/// - An array of strings: `"reasoning_content": ["seg1", "seg2"]` -> `Segments(["seg1", "seg2"])`
496///
497/// The `Segments` variant preserves interleaved reasoning order needed for KV cache-correct
498/// context reconstruction. `segments[i]` is the reasoning that preceded `tool_calls[i]`;
499/// `segments[tool_calls.len()]` is any trailing reasoning after the last tool call.
500#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
501#[serde(untagged)]
502pub enum ReasoningContent {
503    /// Flat string -- single reasoning block or legacy backward-compat form.
504    Text(String),
505    /// Interleaved segments. segments[i] precedes tool_calls[i];
506    /// segments[N] is trailing reasoning after the last tool call.
507    Segments(Vec<String>),
508}
509
510impl ReasoningContent {
511    /// Join all segments (or return text as-is) into a single flat string.
512    pub fn to_flat_string(&self) -> String {
513        match self {
514            ReasoningContent::Text(s) => s.clone(),
515            ReasoningContent::Segments(segs) => segs
516                .iter()
517                .filter(|s| !s.is_empty())
518                .cloned()
519                .collect::<Vec<_>>()
520                .join("\n"),
521        }
522    }
523
524    /// Returns the segments if this is the `Segments` variant, `None` for `Text`.
525    pub fn segments(&self) -> Option<&[String]> {
526        match self {
527            ReasoningContent::Segments(segs) => Some(segs),
528            ReasoningContent::Text(_) => None,
529        }
530    }
531}
532
533// -- Multimodal content types for responses (not in upstream) --
534
535/// Response content part for text in assistant messages
536#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
537pub struct ChatCompletionResponseContentPartText {
538    pub text: String,
539}
540
541/// Response content part for image URLs in assistant messages
542#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
543pub struct ChatCompletionResponseContentPartImageUrl {
544    pub image_url: ImageUrlResponse,
545}
546
547/// Response content part for video URLs in assistant messages
548#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
549pub struct ChatCompletionResponseContentPartVideoUrl {
550    pub video_url: VideoUrlResponse,
551}
552
553/// Response content part for audio URLs in assistant messages
554#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
555pub struct ChatCompletionResponseContentPartAudioUrl {
556    pub audio_url: AudioUrlResponse,
557}
558
559#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
560pub struct ImageUrlResponse {
561    pub url: String,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub detail: Option<String>,
564}
565
566#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
567pub struct VideoUrlResponse {
568    pub url: String,
569}
570
571#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
572pub struct AudioUrlResponse {
573    pub url: String,
574}
575
576/// Content parts for assistant responses supporting multiple modalities
577#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
578#[serde(tag = "type", rename_all = "snake_case")]
579pub enum ChatCompletionResponseContentPart {
580    Text(ChatCompletionResponseContentPartText),
581    ImageUrl(ChatCompletionResponseContentPartImageUrl),
582    VideoUrl(ChatCompletionResponseContentPartVideoUrl),
583    AudioUrl(ChatCompletionResponseContentPartAudioUrl),
584}
585
586/// Assistant message content -- can be a simple string or multimodal content parts.
587///
588/// Upstream uses `Option<String>` for the content field. We extend this to
589/// support multimodal responses (text + images + video + audio) from backends
590/// like vLLM that can return non-text content.
591#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
592#[serde(untagged)]
593pub enum ChatCompletionMessageContent {
594    /// Simple text content (backward compatible)
595    Text(String),
596    /// Array of content parts (for multimodal responses)
597    Parts(Vec<ChatCompletionResponseContentPart>),
598}
599
600// -- Multimodal input types (video/audio URL support, not in upstream) --
601
602#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
603#[builder(name = "VideoUrlArgs")]
604#[builder(pattern = "mutable")]
605#[builder(setter(into, strip_option))]
606#[builder(derive(Debug))]
607#[builder(build_fn(error = "OpenAIError"))]
608pub struct VideoUrl {
609    pub url: Url,
610    pub detail: Option<ImageDetail>,
611    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub uuid: Option<Uuid>,
614}
615
616#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
617#[builder(name = "ChatCompletionRequestMessageContentPartVideoArgs")]
618#[builder(pattern = "mutable")]
619#[builder(setter(into, strip_option))]
620#[builder(derive(Debug))]
621#[builder(build_fn(error = "OpenAIError"))]
622pub struct ChatCompletionRequestMessageContentPartVideo {
623    #[builder(default)]
624    #[serde(default, deserialize_with = "deserialize_optional_media")]
625    pub video_url: Option<VideoUrl>,
626    #[builder(default)]
627    #[serde(skip_serializing_if = "Option::is_none")]
628    /// vLLM-only multimodal processor-cache identity.
629    pub uuid: Option<String>,
630}
631
632#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
633#[builder(name = "AudioUrlArgs")]
634#[builder(pattern = "mutable")]
635#[builder(setter(into, strip_option))]
636#[builder(derive(Debug))]
637#[builder(build_fn(error = "OpenAIError"))]
638pub struct AudioUrl {
639    pub url: Url,
640    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub uuid: Option<Uuid>,
643}
644
645#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
646#[builder(name = "ChatCompletionRequestMessageContentPartAudioUrlArgs")]
647#[builder(pattern = "mutable")]
648#[builder(setter(into, strip_option))]
649#[builder(derive(Debug))]
650#[builder(build_fn(error = "OpenAIError"))]
651pub struct ChatCompletionRequestMessageContentPartAudioUrl {
652    #[builder(default)]
653    #[serde(default, deserialize_with = "deserialize_optional_media")]
654    pub audio_url: Option<AudioUrl>,
655    #[builder(default)]
656    #[serde(skip_serializing_if = "Option::is_none")]
657    /// vLLM-only multimodal processor-cache identity.
658    pub uuid: Option<String>,
659}
660
661// -- Extended request/response types --
662
663/// User message content -- references our extended content part enum.
664#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
665#[serde(untagged)]
666pub enum ChatCompletionRequestUserMessageContent {
667    Text(String),
668    Array(Vec<ChatCompletionRequestUserMessageContentPart>),
669}
670
671#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
672#[builder(name = "ChatCompletionRequestUserMessageArgs")]
673#[builder(pattern = "mutable")]
674#[builder(setter(into, strip_option), default)]
675#[builder(derive(Debug))]
676#[builder(build_fn(error = "OpenAIError"))]
677pub struct ChatCompletionRequestUserMessage {
678    pub content: ChatCompletionRequestUserMessageContent,
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub name: Option<String>,
681}
682
683impl Default for ChatCompletionRequestUserMessageContent {
684    fn default() -> Self {
685        Self::Text(String::new())
686    }
687}
688
689impl From<&str> for ChatCompletionRequestUserMessageContent {
690    fn from(value: &str) -> Self {
691        Self::Text(value.into())
692    }
693}
694
695impl From<String> for ChatCompletionRequestUserMessageContent {
696    fn from(value: String) -> Self {
697        Self::Text(value)
698    }
699}
700
701impl From<Vec<ChatCompletionRequestUserMessageContentPart>>
702    for ChatCompletionRequestUserMessageContent
703{
704    fn from(value: Vec<ChatCompletionRequestUserMessageContentPart>) -> Self {
705        Self::Array(value)
706    }
707}
708
709/// User message content part with video and audio URL support.
710///
711/// Extends upstream `ChatCompletionRequestUserMessageContentPart` with:
712/// - `VideoUrl`: video input for multimodal models
713/// - `AudioUrl`: audio URL input (distinct from base64 InputAudio)
714#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
715#[serde(tag = "type")]
716#[serde(rename_all = "snake_case")]
717pub enum ChatCompletionRequestUserMessageContentPart {
718    Text(ChatCompletionRequestMessageContentPartText),
719    ImageUrl(ChatCompletionRequestMessageContentPartImage),
720    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
721    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
722    InputAudio(ChatCompletionRequestMessageContentPartAudio),
723}
724
725/// Assistant message with reasoning content support.
726///
727/// Extends upstream `ChatCompletionRequestAssistantMessage` with:
728/// - `reasoning_content`: interleaved reasoning segments for KV cache correctness
729///   (DeepSeek-R1, QwQ models)
730#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
731#[builder(name = "ChatCompletionRequestAssistantMessageArgs")]
732#[builder(pattern = "mutable")]
733#[builder(setter(into, strip_option), default)]
734#[builder(derive(Debug))]
735#[builder(build_fn(error = "OpenAIError"))]
736pub struct ChatCompletionRequestAssistantMessage {
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub content: Option<ChatCompletionRequestAssistantMessageContent>,
739    /// Reasoning content from a previous assistant turn.
740    /// Accept both `reasoning_content` (DeepSeek /
741    /// SGLang / TRT-LLM / Vercel AI SDK openai-compatible / LangChain / LiteLLM
742    /// canonical) and `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS
743    /// guidance) on inbound assistant messages, normalizing both to this field.
744    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
745    pub reasoning_content: Option<ReasoningContent>,
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub refusal: Option<String>,
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub name: Option<String>,
750    #[serde(skip_serializing_if = "Option::is_none")]
751    pub audio: Option<ChatCompletionRequestAssistantMessageAudio>,
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
754    #[deprecated]
755    #[serde(skip_serializing_if = "Option::is_none")]
756    pub function_call: Option<FunctionCall>,
757}
758
759/// Chat completion request message enum.
760///
761/// Redefined to use our extended `ChatCompletionRequestAssistantMessage`
762/// (with reasoning_content) and `ChatCompletionRequestUserMessage`
763/// (which references our extended content parts with video/audio).
764#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
765#[serde(tag = "role")]
766#[serde(rename_all = "lowercase")]
767pub enum ChatCompletionRequestMessage {
768    Developer(ChatCompletionRequestDeveloperMessage),
769    System(ChatCompletionRequestSystemMessage),
770    User(ChatCompletionRequestUserMessage),
771    Assistant(ChatCompletionRequestAssistantMessage),
772    Tool(ChatCompletionRequestToolMessage),
773    Function(ChatCompletionRequestFunctionMessage),
774}
775
776/// Backward-compatible name for the service tier reported in responses.
777pub type ServiceTierResponse = ServiceTier;
778
779/// Chat completion response message with multimodal content and reasoning.
780///
781/// Extends upstream `ChatCompletionResponseMessage` with:
782/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
783/// - `reasoning_content`: model reasoning output (DeepSeek-R1, QwQ)
784#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
785pub struct ChatCompletionResponseMessage {
786    /// Always serialized (as `null` when None) so clients can rely on the
787    /// `content` key being present alongside `reasoning_content` or
788    /// `tool_calls`. Matches the upstream OpenAI API shape (DGH-651).
789    pub content: Option<ChatCompletionMessageContent>,
790    /// Always serialized (as `null` when None): the spec marks `refusal` as
791    /// required-and-nullable, and OpenAI emits `"refusal": null` on every
792    /// non-refusal response.
793    pub refusal: Option<String>,
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
796    pub role: Role,
797    #[serde(skip_serializing_if = "Option::is_none")]
798    #[deprecated]
799    pub function_call: Option<FunctionCall>,
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub audio: Option<ChatCompletionResponseMessageAudio>,
802    /// Reasoning content produced by the model (DeepSeek-R1, QwQ).
803    /// Accepts either `reasoning_content` (DeepSeek / SGLang / TRT-LLM
804    /// canonical) or `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS)
805    /// on input via the alias; output-side key selection is handled at the
806    /// HTTP boundary by ai-dynamo/dynamo#11464's `RoutedReasoning` wrapper.
807    /// Not part of the OpenAI spec, so it is omitted entirely when absent.
808    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
809    pub reasoning_content: Option<String>,
810}
811
812/// Stream options with per-chunk usage reporting.
813///
814/// Extends upstream `ChatCompletionStreamOptions` with:
815/// - `continuous_usage_stats`: emit usage in every chunk, not just the final one
816#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
817pub struct ChatCompletionStreamOptions {
818    pub include_usage: bool,
819    /// When true, usage statistics are included in every streaming chunk.
820    /// Backends like vLLM/SGLang support this for real-time token counting.
821    #[serde(default)]
822    pub continuous_usage_stats: bool,
823}
824
825/// Chat completion request with multimodal processor support.
826///
827/// Extends upstream `CreateChatCompletionRequest` with:
828/// - `mm_processor_kwargs`: multimodal processor configuration (vLLM-specific)
829/// - Uses our extended `ChatCompletionRequestMessage` (with reasoning, video/audio)
830/// - Uses our extended `ChatCompletionStreamOptions` (with continuous_usage_stats)
831#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
832#[builder(name = "CreateChatCompletionRequestArgs")]
833#[builder(pattern = "mutable")]
834#[builder(setter(into, strip_option), default)]
835#[builder(derive(Debug))]
836#[builder(build_fn(error = "OpenAIError"))]
837pub struct CreateChatCompletionRequest {
838    pub messages: Vec<ChatCompletionRequestMessage>,
839    pub model: String,
840    /// Multimodal processor configuration (vLLM-specific)
841    #[serde(skip_serializing_if = "Option::is_none")]
842    pub mm_processor_kwargs: Option<serde_json::Value>,
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub store: Option<bool>,
845    #[serde(skip_serializing_if = "Option::is_none")]
846    pub reasoning_effort: Option<ReasoningEffort>,
847    #[serde(skip_serializing_if = "Option::is_none")]
848    pub metadata: Option<serde_json::Value>,
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub frequency_penalty: Option<f32>,
851    #[serde(skip_serializing_if = "Option::is_none")]
852    pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
853    #[serde(skip_serializing_if = "Option::is_none")]
854    pub logprobs: Option<bool>,
855    #[serde(skip_serializing_if = "Option::is_none")]
856    pub top_logprobs: Option<u8>,
857    #[deprecated]
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pub max_tokens: Option<u32>,
860    #[serde(skip_serializing_if = "Option::is_none")]
861    pub max_completion_tokens: Option<u32>,
862    #[serde(skip_serializing_if = "Option::is_none")]
863    pub n: Option<u8>,
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
866    #[serde(skip_serializing_if = "Option::is_none")]
867    pub prediction: Option<PredictionContent>,
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub audio: Option<ChatCompletionAudio>,
870    #[serde(skip_serializing_if = "Option::is_none")]
871    pub presence_penalty: Option<f32>,
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub response_format: Option<ResponseFormat>,
874    #[serde(skip_serializing_if = "Option::is_none")]
875    pub seed: Option<i64>,
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub service_tier: Option<ServiceTier>,
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub stop: Option<Stop>,
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub stream: Option<bool>,
882    #[serde(skip_serializing_if = "Option::is_none")]
883    pub stream_options: Option<ChatCompletionStreamOptions>,
884    #[serde(skip_serializing_if = "Option::is_none")]
885    pub temperature: Option<f32>,
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub top_p: Option<f32>,
888    #[serde(skip_serializing_if = "Option::is_none")]
889    pub tools: Option<Vec<ChatCompletionTool>>,
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub tool_choice: Option<ChatCompletionToolChoiceOption>,
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub parallel_tool_calls: Option<bool>,
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub user: Option<String>,
896    #[deprecated]
897    #[serde(skip_serializing_if = "Option::is_none")]
898    pub function_call: Option<ChatCompletionFunctionCall>,
899    #[deprecated]
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub functions: Option<Vec<ChatCompletionFunctions>>,
902    #[serde(skip_serializing_if = "Option::is_none")]
903    pub web_search_options: Option<WebSearchOptions>,
904}
905
906/// Chat choice with extended response message.
907///
908/// Uses our `ChatCompletionResponseMessage` (multimodal content + reasoning).
909#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
910pub struct ChatChoice {
911    pub index: u32,
912    pub message: ChatCompletionResponseMessage,
913    pub finish_reason: Option<FinishReason>,
914    pub logprobs: Option<ChatChoiceLogprobs>,
915}
916
917/// Serializes `usage` through a shadow struct that omits absent optional
918/// fields.
919///
920/// Upstream async-openai derives serialize `None` usage-details fields as
921/// explicit `null` (e.g. `"audio_tokens": null`), but the spec marks every
922/// usage-details field optional and non-nullable, so absent fields must be
923/// omitted (OpenAI emits `"audio_tokens": 0`, never `null`). The shadow
924/// keeps upstream `CompletionUsage` in the public API — replacing it with a
925/// same-named local type would break callers that pass upstream values.
926fn serialize_usage_omitting_absent<S>(
927    usage: &Option<CompletionUsage>,
928    serializer: S,
929) -> Result<S::Ok, S::Error>
930where
931    S: serde::Serializer,
932{
933    #[derive(Serialize)]
934    struct PromptDetailsShadow {
935        #[serde(skip_serializing_if = "Option::is_none")]
936        audio_tokens: Option<u32>,
937        #[serde(skip_serializing_if = "Option::is_none")]
938        cached_tokens: Option<u32>,
939    }
940
941    #[derive(Serialize)]
942    struct CompletionDetailsShadow {
943        #[serde(skip_serializing_if = "Option::is_none")]
944        accepted_prediction_tokens: Option<u32>,
945        #[serde(skip_serializing_if = "Option::is_none")]
946        audio_tokens: Option<u32>,
947        #[serde(skip_serializing_if = "Option::is_none")]
948        reasoning_tokens: Option<u32>,
949        #[serde(skip_serializing_if = "Option::is_none")]
950        rejected_prediction_tokens: Option<u32>,
951    }
952
953    #[derive(Serialize)]
954    struct UsageShadow {
955        prompt_tokens: u32,
956        completion_tokens: u32,
957        total_tokens: u32,
958        #[serde(skip_serializing_if = "Option::is_none")]
959        prompt_tokens_details: Option<PromptDetailsShadow>,
960        #[serde(skip_serializing_if = "Option::is_none")]
961        completion_tokens_details: Option<CompletionDetailsShadow>,
962    }
963
964    match usage {
965        None => serializer.serialize_none(),
966        Some(u) => UsageShadow {
967            prompt_tokens: u.prompt_tokens,
968            completion_tokens: u.completion_tokens,
969            total_tokens: u.total_tokens,
970            prompt_tokens_details: u
971                .prompt_tokens_details
972                .as_ref()
973                .map(|d| PromptDetailsShadow {
974                    audio_tokens: d.audio_tokens,
975                    cached_tokens: d.cached_tokens,
976                }),
977            completion_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
978                CompletionDetailsShadow {
979                    accepted_prediction_tokens: d.accepted_prediction_tokens,
980                    audio_tokens: d.audio_tokens,
981                    reasoning_tokens: d.reasoning_tokens,
982                    rejected_prediction_tokens: d.rejected_prediction_tokens,
983                }
984            }),
985        }
986        .serialize(serializer),
987    }
988}
989
990/// Non-streaming chat completion response.
991///
992/// `service_tier`, `system_fingerprint`, and `usage` are optional in the
993/// spec and omitted (not serialized as `null`) when absent, matching
994/// OpenAI output. `choices[].finish_reason` and `choices[].logprobs` stay
995/// always-present: the spec marks them required (nullable for `logprobs`).
996#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
997pub struct CreateChatCompletionResponse {
998    pub id: String,
999    pub choices: Vec<ChatChoice>,
1000    pub created: u32,
1001    pub model: String,
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub service_tier: Option<ServiceTierResponse>,
1004    #[serde(skip_serializing_if = "Option::is_none")]
1005    pub system_fingerprint: Option<String>,
1006    pub object: String,
1007    #[serde(
1008        skip_serializing_if = "Option::is_none",
1009        serialize_with = "serialize_usage_omitting_absent"
1010    )]
1011    pub usage: Option<CompletionUsage>,
1012}
1013
1014pub type ChatCompletionResponseStream =
1015    Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
1016
1017/// Streaming delta with reasoning content.
1018///
1019/// Extends upstream `ChatCompletionStreamResponseDelta` with:
1020/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
1021/// - `reasoning_content`: streaming reasoning tokens (DeepSeek-R1, QwQ)
1022#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1023pub struct ChatCompletionStreamResponseDelta {
1024    #[serde(skip_serializing_if = "Option::is_none")]
1025    pub content: Option<ChatCompletionMessageContent>,
1026    #[serde(skip_serializing_if = "Option::is_none")]
1027    pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
1028    #[serde(skip_serializing_if = "Option::is_none")]
1029    pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
1030    #[serde(skip_serializing_if = "Option::is_none")]
1031    pub role: Option<Role>,
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    pub refusal: Option<String>,
1034    /// Streaming reasoning content (DeepSeek-R1, QwQ models).
1035    /// Accepts either `reasoning_content` (DeepSeek / SGLang / TRT-LLM
1036    /// canonical) or `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS)
1037    /// on input via the alias; output-side key selection is handled at the
1038    /// HTTP boundary by ai-dynamo/dynamo#11464's `RoutedReasoning` wrapper.
1039    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
1040    pub reasoning_content: Option<String>,
1041}
1042
1043#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1044pub struct ChatCompletionStreamResponseDeltaFunctionCall {
1045    #[serde(skip_serializing_if = "Option::is_none")]
1046    pub name: Option<String>,
1047    #[serde(
1048        default,
1049        deserialize_with = "deserialize_arguments_opt",
1050        skip_serializing_if = "Option::is_none"
1051    )]
1052    pub arguments: Option<String>,
1053}
1054
1055/// Streaming chat choice.
1056#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1057pub struct ChatChoiceStream {
1058    pub index: u32,
1059    pub delta: ChatCompletionStreamResponseDelta,
1060    pub finish_reason: Option<FinishReason>,
1061    pub logprobs: Option<ChatChoiceLogprobs>,
1062}
1063
1064/// Streaming chat completion response with extended choices.
1065///
1066/// `service_tier`, `system_fingerprint`, and `usage` are optional in the
1067/// spec and omitted (not serialized as `null`) when absent. Note: with
1068/// `stream_options.include_usage`, OpenAI emits `"usage": null` on every
1069/// chunk before the final one; callers needing that exact shape must
1070/// inject the key at the HTTP boundary.
1071#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1072pub struct CreateChatCompletionStreamResponse {
1073    pub id: String,
1074    pub choices: Vec<ChatChoiceStream>,
1075    pub created: u32,
1076    pub model: String,
1077    #[serde(skip_serializing_if = "Option::is_none")]
1078    pub service_tier: Option<ServiceTierResponse>,
1079    #[serde(skip_serializing_if = "Option::is_none")]
1080    pub system_fingerprint: Option<String>,
1081    pub object: String,
1082    #[serde(
1083        skip_serializing_if = "Option::is_none",
1084        serialize_with = "serialize_usage_omitting_absent"
1085    )]
1086    pub usage: Option<CompletionUsage>,
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092
1093    #[test]
1094    fn stop_accepts_token_id_array() {
1095        let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
1096
1097        assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
1098    }
1099
1100    #[test]
1101    fn stop_accepts_string_and_string_array() {
1102        let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
1103
1104        assert_eq!(stop, Stop::String(" The".to_string()));
1105
1106        let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
1107
1108        assert_eq!(
1109            stop,
1110            Stop::StringArray(vec!["A".to_string(), "B".to_string()])
1111        );
1112    }
1113
1114    #[test]
1115    fn stop_token_id_display_string_remains_string_stop() {
1116        let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
1117
1118        assert_eq!(stop, Stop::String("token_id:576".to_string()));
1119
1120        let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
1121
1122        assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
1123    }
1124
1125    #[test]
1126    fn stop_rejects_single_token_id() {
1127        let result = serde_json::from_value::<Stop>(serde_json::json!(576));
1128
1129        assert!(result.is_err());
1130    }
1131
1132    #[test]
1133    fn stop_converts_from_upstream_stop_configuration() {
1134        let upstream =
1135            async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
1136
1137        assert_eq!(
1138            Stop::from(upstream),
1139            Stop::StringArray(vec!["END".to_string()])
1140        );
1141    }
1142
1143    #[test]
1144    fn request_builder_accepts_upstream_reasoning_effort() {
1145        let request = CreateChatCompletionRequestArgs::default()
1146            .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
1147            .build()
1148            .unwrap();
1149
1150        assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
1151    }
1152
1153    #[test]
1154    fn tool_call_defaults_type_on_deserialize() {
1155        let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1156            "id": "call_123",
1157            "function": {
1158                "name": "get_weather",
1159                "arguments": "{\"location\":\"SF\"}"
1160            }
1161        }))
1162        .unwrap();
1163
1164        assert_eq!(tool_call.r#type, FunctionType::Function);
1165    }
1166
1167    #[test]
1168    fn tool_call_serializes_type_for_wire_compat() {
1169        let tool_call = ChatCompletionMessageToolCall {
1170            id: "call_123".into(),
1171            r#type: FunctionType::Function,
1172            function: FunctionCall {
1173                name: "get_weather".into(),
1174                arguments: "{\"location\":\"SF\"}".into(),
1175            },
1176        };
1177
1178        let json = serde_json::to_value(tool_call).unwrap();
1179        assert_eq!(json["type"], "function");
1180    }
1181
1182    // -- dict-format arguments tests --
1183
1184    #[test]
1185    fn function_call_accepts_string_arguments() {
1186        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1187            "name": "get_weather",
1188            "arguments": "{\"location\":\"SF\"}"
1189        }))
1190        .unwrap();
1191        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1192    }
1193
1194    #[test]
1195    fn function_call_accepts_dict_arguments() {
1196        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1197            "name": "get_weather",
1198            "arguments": {"location": "SF"}
1199        }))
1200        .unwrap();
1201        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1202    }
1203
1204    #[test]
1205    fn function_call_rejects_integer_arguments() {
1206        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1207            "name": "f",
1208            "arguments": 42
1209        }));
1210        assert!(result.is_err());
1211    }
1212
1213    #[test]
1214    fn function_call_rejects_boolean_arguments() {
1215        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1216            "name": "f",
1217            "arguments": true
1218        }));
1219        assert!(result.is_err());
1220    }
1221
1222    #[test]
1223    fn function_call_rejects_null_arguments() {
1224        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1225            "name": "f",
1226            "arguments": null
1227        }));
1228        assert!(result.is_err());
1229    }
1230
1231    #[test]
1232    fn function_call_rejects_array_arguments() {
1233        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1234            "name": "f",
1235            "arguments": [1, 2, 3]
1236        }));
1237        assert!(result.is_err());
1238    }
1239
1240    #[test]
1241    fn function_call_stream_null_arguments_produces_none() {
1242        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1243            "name": "f",
1244            "arguments": null
1245        }))
1246        .unwrap();
1247        assert_eq!(fcs.arguments, None);
1248    }
1249
1250    #[test]
1251    fn function_call_stream_rejects_integer_arguments() {
1252        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1253            "name": "f",
1254            "arguments": 42
1255        }));
1256        assert!(result.is_err());
1257    }
1258
1259    #[test]
1260    fn function_call_stream_rejects_boolean_arguments() {
1261        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1262            "name": "f",
1263            "arguments": true
1264        }));
1265        assert!(result.is_err());
1266    }
1267
1268    #[test]
1269    fn function_call_stream_accepts_dict_arguments() {
1270        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1271            "name": "get_weather",
1272            "arguments": {"location": "SF"}
1273        }))
1274        .unwrap();
1275        assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1276    }
1277
1278    #[test]
1279    fn function_call_stream_accepts_null_arguments() {
1280        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1281            "name": "get_weather"
1282        }))
1283        .unwrap();
1284        assert_eq!(fcs.arguments, None);
1285    }
1286
1287    #[test]
1288    fn tool_call_with_dict_arguments_roundtrip() {
1289        let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1290            "id": "call_abc",
1291            "type": "function",
1292            "function": {
1293                "name": "search",
1294                "arguments": {"query": "hello", "limit": 10}
1295            }
1296        }))
1297        .unwrap();
1298        // Compare as parsed JSON values since key order is non-deterministic
1299        let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1300        assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1301        // Re-serialisation produces a string, not an object
1302        let json = serde_json::to_value(&tc).unwrap();
1303        assert!(json["function"]["arguments"].is_string());
1304    }
1305
1306    #[test]
1307    fn stream_delta_function_call_accepts_dict_arguments() {
1308        let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1309            serde_json::from_value(serde_json::json!({
1310                "name": "get_weather",
1311                "arguments": {"location": "SF"}
1312            }))
1313            .unwrap();
1314        assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1315    }
1316
1317    fn parse_content_part(json: serde_json::Value) -> ChatCompletionRequestUserMessageContentPart {
1318        serde_json::from_value(json).expect("content part deserialization failed")
1319    }
1320
1321    #[test]
1322    fn image_url_url_and_top_level_uuid() {
1323        let part = parse_content_part(serde_json::json!({
1324            "type": "image_url",
1325            "image_url": {"url": "https://x.example/y.png"},
1326            "uuid": "image-123"
1327        }));
1328
1329        match part {
1330            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1331                assert_eq!(part.uuid.as_deref(), Some("image-123"));
1332                assert_eq!(
1333                    part.image_url.as_ref().map(|image| image.url.as_str()),
1334                    Some("https://x.example/y.png")
1335                );
1336            }
1337            _ => panic!("expected image_url part"),
1338        }
1339    }
1340
1341    #[test]
1342    fn image_url_null_and_top_level_uuid() {
1343        let part = parse_content_part(serde_json::json!({
1344            "type": "image_url",
1345            "image_url": null,
1346            "uuid": "sku-1234-a"
1347        }));
1348
1349        match part {
1350            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1351                assert!(part.image_url.is_none());
1352                assert_eq!(part.uuid.as_deref(), Some("sku-1234-a"));
1353            }
1354            _ => panic!("expected image_url part"),
1355        }
1356    }
1357
1358    #[test]
1359    fn empty_media_urls_deserialize_as_uuid_only() {
1360        for (part_type, media_field, uuid) in [
1361            ("image_url", "image_url", "image-cache-key"),
1362            ("video_url", "video_url", "video-cache-key"),
1363            ("audio_url", "audio_url", "audio-cache-key"),
1364        ] {
1365            let part = parse_content_part(serde_json::json!({
1366                "type": part_type,
1367                (media_field): {"url": ""},
1368                "uuid": uuid
1369            }));
1370            let json = serde_json::to_value(part).unwrap();
1371
1372            assert!(json[media_field].is_null());
1373            assert_eq!(json["uuid"], uuid);
1374        }
1375    }
1376
1377    #[test]
1378    fn image_url_null_without_uuid_deserializes_for_use_site_validation() {
1379        let part = parse_content_part(serde_json::json!({
1380            "type": "image_url",
1381            "image_url": null
1382        }));
1383
1384        match part {
1385            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1386                assert!(part.image_url.is_none());
1387                assert!(part.uuid.is_none());
1388            }
1389            _ => panic!("expected image_url part"),
1390        }
1391    }
1392
1393    #[test]
1394    fn image_url_serialize_uuid_only_uses_null_image_url() {
1395        let part = ChatCompletionRequestMessageContentPartImage {
1396            image_url: None,
1397            uuid: Some("image-123".to_string()),
1398        };
1399        let json = serde_json::to_value(part).unwrap();
1400
1401        assert!(json["image_url"].is_null());
1402        assert_eq!(json["uuid"], "image-123");
1403    }
1404
1405    #[test]
1406    fn cached_media_builders_allow_omitting_urls() {
1407        let image = ChatCompletionRequestMessageContentPartImageArgs::default()
1408            .uuid("image-123")
1409            .build()
1410            .unwrap();
1411        let video = ChatCompletionRequestMessageContentPartVideoArgs::default()
1412            .uuid("video-123")
1413            .build()
1414            .unwrap();
1415        let audio = ChatCompletionRequestMessageContentPartAudioUrlArgs::default()
1416            .uuid("audio-123")
1417            .build()
1418            .unwrap();
1419
1420        let image_json = serde_json::to_value(image).unwrap();
1421        let video_json = serde_json::to_value(video).unwrap();
1422        let audio_json = serde_json::to_value(audio).unwrap();
1423        assert!(image_json["image_url"].is_null());
1424        assert!(video_json["video_url"].is_null());
1425        assert!(audio_json["audio_url"].is_null());
1426    }
1427
1428    #[test]
1429    fn image_url_uuid_accepts_opaque_string() {
1430        let part = parse_content_part(serde_json::json!({
1431            "type": "image_url",
1432            "image_url": {"url": "https://x.example/y.png"},
1433            "uuid": "img-ac3921de680bb217"
1434        }));
1435
1436        match part {
1437            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1438                assert_eq!(part.uuid.as_deref(), Some("img-ac3921de680bb217"));
1439            }
1440            _ => panic!("expected image_url part"),
1441        }
1442    }
1443
1444    #[test]
1445    fn url_conversions_preserve_required_urls() {
1446        let image: ImageUrl = "https://x.example/image.png".into();
1447        let video: VideoUrl = "https://x.example/video.mp4".into();
1448        let audio: AudioUrl = "https://x.example/audio.wav".into();
1449
1450        assert_eq!(image.url.as_str(), "https://x.example/image.png");
1451        assert_eq!(video.url.as_str(), "https://x.example/video.mp4");
1452        assert_eq!(audio.url.as_str(), "https://x.example/audio.wav");
1453    }
1454
1455    #[test]
1456    fn invalid_media_urls_remain_rejected() {
1457        for (part_type, media_field) in [
1458            ("image_url", "image_url"),
1459            ("video_url", "video_url"),
1460            ("audio_url", "audio_url"),
1461        ] {
1462            let result = serde_json::from_value::<ChatCompletionRequestUserMessageContentPart>(
1463                serde_json::json!({
1464                    "type": part_type,
1465                    (media_field): {"url": "not a url"},
1466                    "uuid": "cache-key"
1467                }),
1468            );
1469
1470            assert!(result.is_err(), "{part_type} accepted an invalid URL");
1471        }
1472    }
1473
1474    #[test]
1475    fn legacy_nested_media_uuids_remain_accepted() {
1476        let legacy_uuid = "92b888ad-e64a-478f-b688-5091e16544e3";
1477
1478        for (part_type, media_field, url) in [
1479            ("image_url", "image_url", "https://x.example/image.png"),
1480            ("video_url", "video_url", "https://x.example/video.mp4"),
1481            ("audio_url", "audio_url", "https://x.example/audio.wav"),
1482        ] {
1483            let part = parse_content_part(serde_json::json!({
1484                "type": part_type,
1485                (media_field): {"url": url, "uuid": legacy_uuid}
1486            }));
1487            let json = serde_json::to_value(part).unwrap();
1488
1489            assert_eq!(json[media_field]["url"], url);
1490            assert_eq!(json[media_field]["uuid"], legacy_uuid);
1491            assert!(json.get("uuid").is_none());
1492        }
1493    }
1494
1495    #[test]
1496    fn video_url_null_and_top_level_uuid() {
1497        let part = parse_content_part(serde_json::json!({
1498            "type": "video_url",
1499            "video_url": null,
1500            "uuid": "video-cache-key"
1501        }));
1502
1503        match part {
1504            ChatCompletionRequestUserMessageContentPart::VideoUrl(part) => {
1505                assert!(part.video_url.is_none());
1506                assert_eq!(part.uuid.as_deref(), Some("video-cache-key"));
1507            }
1508            _ => panic!("expected video_url part"),
1509        }
1510    }
1511
1512    #[test]
1513    fn audio_url_null_and_top_level_uuid() {
1514        let part = parse_content_part(serde_json::json!({
1515            "type": "audio_url",
1516            "audio_url": null,
1517            "uuid": "audio-cache-key"
1518        }));
1519
1520        match part {
1521            ChatCompletionRequestUserMessageContentPart::AudioUrl(part) => {
1522                assert!(part.audio_url.is_none());
1523                assert_eq!(part.uuid.as_deref(), Some("audio-cache-key"));
1524            }
1525            _ => panic!("expected audio_url part"),
1526        }
1527    }
1528
1529    #[test]
1530    fn message_content_array_preserves_uuid_alignment() {
1531        let payload = serde_json::json!({
1532            "role": "user",
1533            "content": [
1534                {"type": "text", "text": "describe these"},
1535                {
1536                    "type": "image_url",
1537                    "image_url": {"url": "https://x.example/img1.png"},
1538                    "uuid": "image-1"
1539                },
1540                {"type": "image_url", "image_url": null, "uuid": "image-1"}
1541            ]
1542        });
1543        let message: ChatCompletionRequestUserMessage = serde_json::from_value(payload).unwrap();
1544        let ChatCompletionRequestUserMessageContent::Array(parts) = message.content else {
1545            panic!("expected content array");
1546        };
1547
1548        assert_eq!(parts.len(), 3);
1549        match &parts[1] {
1550            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1551                assert!(
1552                    part.image_url
1553                        .as_ref()
1554                        .map(|image| image.url.as_str())
1555                        .is_some()
1556                );
1557                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1558            }
1559            _ => panic!("parts[1] should be image_url"),
1560        }
1561        match &parts[2] {
1562            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1563                assert!(part.image_url.is_none());
1564                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1565            }
1566            _ => panic!("parts[2] should be image_url"),
1567        }
1568    }
1569
1570    #[test]
1571    fn tool_message_accepts_media_content() {
1572        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
1573            "role": "tool",
1574            "tool_call_id": "call_media",
1575            "content": [
1576                {"type": "text", "text": "Screenshot captured"},
1577                {
1578                    "type": "image_url",
1579                    "image_url": {
1580                        "url": "data:image/png;base64,aGVsbG8="
1581                    }
1582                },
1583                {
1584                    "type": "video_url",
1585                    "video_url": {
1586                        "url": "https://example.com/clip.mp4"
1587                    }
1588                },
1589                {
1590                    "type": "audio_url",
1591                    "audio_url": {
1592                        "url": "https://example.com/audio.wav"
1593                    }
1594                }
1595            ]
1596        }))
1597        .unwrap();
1598
1599        let ChatCompletionRequestMessage::Tool(tool) = message else {
1600            panic!("expected tool message");
1601        };
1602        let ChatCompletionRequestToolMessageContent::Array(parts) = tool.content else {
1603            panic!("expected array content");
1604        };
1605        assert!(matches!(
1606            parts[1],
1607            ChatCompletionRequestToolMessageContentPart::ImageUrl(_)
1608        ));
1609        assert!(matches!(
1610            parts[2],
1611            ChatCompletionRequestToolMessageContentPart::VideoUrl(_)
1612        ));
1613        assert!(matches!(
1614            parts[3],
1615            ChatCompletionRequestToolMessageContentPart::AudioUrl(_)
1616        ));
1617    }
1618
1619    #[test]
1620    fn chat_logprob_serializes_token_id_when_present() {
1621        let logprob = ChatCompletionTokenLogprob {
1622            token: " hello".into(),
1623            logprob: -0.12,
1624            token_id: Some(123),
1625            bytes: Some(vec![32, 104, 101, 108, 108, 111]),
1626            top_logprobs: vec![],
1627        };
1628
1629        let json = serde_json::to_value(logprob).unwrap();
1630
1631        assert_eq!(json["token_id"], 123);
1632    }
1633
1634    #[test]
1635    fn chat_logprob_deserializes_optional_fields() {
1636        let choice_logprobs: ChatChoiceLogprobs = serde_json::from_value(serde_json::json!({
1637            "content": [{
1638                "token": " hello",
1639                "logprob": -0.12,
1640                "top_logprobs": []
1641            }]
1642        }))
1643        .unwrap();
1644        let token_logprob: ChatCompletionTokenLogprob = serde_json::from_value(serde_json::json!({
1645            "token": " hello",
1646            "logprob": -0.12,
1647            "token_id": 123,
1648            "bytes": [32, 104, 101, 108, 108, 111],
1649            "top_logprobs": []
1650        }))
1651        .unwrap();
1652
1653        assert_eq!(choice_logprobs.content.as_ref().unwrap()[0].token_id, None);
1654        assert!(choice_logprobs.refusal.is_none());
1655        assert_eq!(token_logprob.token_id, Some(123));
1656        assert_eq!(token_logprob.bytes, Some(vec![32, 104, 101, 108, 108, 111]));
1657    }
1658
1659    #[test]
1660    fn chat_logprob_preserves_nullable_fields() {
1661        let choice_logprobs = ChatChoiceLogprobs {
1662            content: None,
1663            refusal: None,
1664        };
1665        let token_logprob = ChatCompletionTokenLogprob {
1666            token: " hello".into(),
1667            logprob: -0.12,
1668            token_id: None,
1669            bytes: None,
1670            top_logprobs: vec![],
1671        };
1672
1673        let choice_json = serde_json::to_value(choice_logprobs).unwrap();
1674        let token_json = serde_json::to_value(token_logprob).unwrap();
1675
1676        assert_eq!(choice_json["content"], serde_json::Value::Null);
1677        assert_eq!(choice_json["refusal"], serde_json::Value::Null);
1678        assert!(token_json.get("token_id").is_none());
1679        assert_eq!(token_json["bytes"], serde_json::Value::Null);
1680    }
1681
1682    #[test]
1683    #[allow(deprecated)]
1684    fn chat_response_omits_absent_optional_fields() {
1685        let response = CreateChatCompletionResponse {
1686            id: "chatcmpl_dummy".into(),
1687            choices: vec![ChatChoice {
1688                index: 0,
1689                message: ChatCompletionResponseMessage {
1690                    content: Some(ChatCompletionMessageContent::Text("hello".into())),
1691                    refusal: None,
1692                    tool_calls: None,
1693                    role: Role::Assistant,
1694                    function_call: None,
1695                    audio: None,
1696                    reasoning_content: None,
1697                },
1698                finish_reason: Some(FinishReason::Stop),
1699                logprobs: None,
1700            }],
1701            created: 0,
1702            model: "dummy-model".into(),
1703            service_tier: None,
1704            system_fingerprint: None,
1705            object: "chat.completion".into(),
1706            usage: None,
1707        };
1708
1709        let json = serde_json::to_value(response).unwrap();
1710
1711        for absent in ["usage", "service_tier", "system_fingerprint"] {
1712            assert!(json.get(absent).is_none(), "{absent} should be omitted");
1713        }
1714        let choice = &json["choices"][0];
1715        assert_eq!(choice["finish_reason"], "stop");
1716        assert_eq!(choice["logprobs"], serde_json::Value::Null);
1717        let message = &choice["message"];
1718        assert_eq!(message["refusal"], serde_json::Value::Null);
1719        for absent in ["tool_calls", "function_call", "audio", "reasoning_content"] {
1720            assert!(
1721                message.get(absent).is_none(),
1722                "message.{absent} should be omitted"
1723            );
1724        }
1725    }
1726
1727    #[test]
1728    fn stream_response_omits_absent_optional_fields() {
1729        let chunk = CreateChatCompletionStreamResponse {
1730            id: "chatcmpl_dummy".into(),
1731            choices: vec![ChatChoiceStream {
1732                index: 0,
1733                delta: ChatCompletionStreamResponseDelta {
1734                    content: Some(ChatCompletionMessageContent::Text("hello".into())),
1735                    function_call: None,
1736                    tool_calls: None,
1737                    role: None,
1738                    refusal: None,
1739                    reasoning_content: None,
1740                },
1741                finish_reason: None,
1742                logprobs: None,
1743            }],
1744            created: 0,
1745            model: "dummy-model".into(),
1746            service_tier: None,
1747            system_fingerprint: None,
1748            object: "chat.completion.chunk".into(),
1749            usage: None,
1750        };
1751
1752        let json = serde_json::to_value(chunk).unwrap();
1753
1754        for absent in ["usage", "service_tier", "system_fingerprint"] {
1755            assert!(json.get(absent).is_none(), "{absent} should be omitted");
1756        }
1757    }
1758
1759    #[test]
1760    fn stream_tool_call_continuation_chunk_omits_absent_fields() {
1761        let chunk = ChatCompletionMessageToolCallChunk {
1762            index: 0,
1763            id: None,
1764            r#type: None,
1765            function: Some(FunctionCallStream {
1766                name: None,
1767                arguments: Some("{\"a\":".into()),
1768            }),
1769        };
1770
1771        let json = serde_json::to_value(chunk).unwrap();
1772
1773        assert!(json.get("id").is_none());
1774        assert!(json.get("type").is_none());
1775        assert!(json["function"].get("name").is_none());
1776        assert_eq!(json["function"]["arguments"], "{\"a\":");
1777    }
1778
1779    #[test]
1780    fn stream_delta_function_call_omits_absent_fields() {
1781        let function_call = ChatCompletionStreamResponseDeltaFunctionCall {
1782            name: None,
1783            arguments: Some("{}".into()),
1784        };
1785
1786        let json = serde_json::to_value(function_call).unwrap();
1787
1788        assert!(json.get("name").is_none());
1789        assert_eq!(json["arguments"], "{}");
1790    }
1791
1792    #[test]
1793    fn usage_details_omit_absent_fields() {
1794        let response = CreateChatCompletionResponse {
1795            id: "chatcmpl_dummy".into(),
1796            choices: vec![],
1797            created: 0,
1798            model: "dummy-model".into(),
1799            service_tier: None,
1800            system_fingerprint: None,
1801            object: "chat.completion".into(),
1802            usage: Some(CompletionUsage {
1803                prompt_tokens: 10,
1804                completion_tokens: 25,
1805                total_tokens: 35,
1806                prompt_tokens_details: Some(PromptTokensDetails {
1807                    audio_tokens: None,
1808                    cached_tokens: Some(0),
1809                }),
1810                completion_tokens_details: Some(CompletionTokensDetails {
1811                    reasoning_tokens: Some(5),
1812                    ..Default::default()
1813                }),
1814            }),
1815        };
1816
1817        let json = serde_json::to_value(&response).unwrap();
1818        let usage = &json["usage"];
1819
1820        assert_eq!(usage["total_tokens"], 35);
1821        assert_eq!(usage["prompt_tokens_details"]["cached_tokens"], 0);
1822        assert!(
1823            usage["prompt_tokens_details"].get("audio_tokens").is_none(),
1824            "audio_tokens should be omitted, not null"
1825        );
1826        assert_eq!(usage["completion_tokens_details"]["reasoning_tokens"], 5);
1827        for absent in [
1828            "accepted_prediction_tokens",
1829            "audio_tokens",
1830            "rejected_prediction_tokens",
1831        ] {
1832            assert!(
1833                usage["completion_tokens_details"].get(absent).is_none(),
1834                "{absent} should be omitted"
1835            );
1836        }
1837
1838        let roundtrip: CreateChatCompletionResponse = serde_json::from_value(json).unwrap();
1839        assert_eq!(roundtrip, response);
1840    }
1841}