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, ChatCompletionAudioFormat, ChatCompletionAudioVoice,
26    ChatCompletionFunctionCall, ChatCompletionFunctions, ChatCompletionFunctionsArgs,
27    ChatCompletionRequestAssistantMessageAudio, ChatCompletionRequestAssistantMessageContent,
28    ChatCompletionRequestAssistantMessageContentPart, ChatCompletionRequestDeveloperMessage,
29    ChatCompletionRequestDeveloperMessageArgs, ChatCompletionRequestDeveloperMessageContent,
30    ChatCompletionRequestFunctionMessage, ChatCompletionRequestFunctionMessageArgs,
31    ChatCompletionRequestMessageContentPartAudio, ChatCompletionRequestMessageContentPartRefusal,
32    ChatCompletionRequestMessageContentPartText, ChatCompletionRequestSystemMessageContent,
33    ChatCompletionRequestSystemMessageContentPart, ChatCompletionResponseMessageAudio, Choice,
34    CompletionFinishReason, CompletionTokensDetails, CompletionUsage, FunctionObject,
35    FunctionObjectArgs, ImageDetail, InputAudio, InputAudioFormat, Logprobs, PredictionContent,
36    PredictionContentContent, Prompt, PromptTokensDetails, ResponseFormat,
37    ResponseFormatJsonSchema, Role, ServiceTier, TopLogprobs, WebSearchContextSize,
38    WebSearchLocation, WebSearchOptions, WebSearchUserLocation, WebSearchUserLocationType,
39};
40
41/// OpenAI stop configuration, with Dynamo's token-id stop extension.
42///
43/// The standard OpenAI shape accepts a string or string array. Dynamo also
44/// accepts an integer array, e.g. `"stop": [576]`, to express token-id stop
45/// conditions for tokenized in/out workflows. Strings like `"token_id:576"`
46/// remain ordinary string stops; the `token_id:<id>` format is only an output
47/// display format for logprobs.
48#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
49#[serde(untagged)]
50pub enum Stop {
51    String(String),
52    StringArray(Vec<String>),
53    TokenIdArray(Vec<u32>),
54}
55
56// Use anyOf because an empty array matches both array variants.
57#[cfg(feature = "protocol-schema")]
58impl utoipa::PartialSchema for Stop {
59    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
60        utoipa::openapi::schema::AnyOfBuilder::new()
61            .description(Some(
62                "OpenAI stop configuration, with Dynamo's token-id stop extension.",
63            ))
64            .item(String::schema())
65            .item(Vec::<String>::schema())
66            .item(Vec::<u32>::schema())
67            .into()
68    }
69}
70
71#[cfg(feature = "protocol-schema")]
72impl utoipa::ToSchema for Stop {
73    fn name() -> std::borrow::Cow<'static, str> {
74        "dynamo_protocols.chat.Stop".into()
75    }
76}
77
78impl Stop {
79    pub fn strings(&self) -> Option<Vec<String>> {
80        match self {
81            Stop::String(s) => Some(vec![s.clone()]),
82            Stop::StringArray(arr) => Some(arr.clone()),
83            Stop::TokenIdArray(_) => None,
84        }
85    }
86
87    pub fn token_ids(&self) -> Option<Vec<u32>> {
88        match self {
89            Stop::TokenIdArray(arr) => Some(arr.clone()),
90            Stop::String(_) | Stop::StringArray(_) => None,
91        }
92    }
93}
94
95impl From<String> for Stop {
96    fn from(value: String) -> Self {
97        Stop::String(value)
98    }
99}
100
101impl From<&str> for Stop {
102    fn from(value: &str) -> Self {
103        Stop::String(value.to_string())
104    }
105}
106
107impl From<Vec<String>> for Stop {
108    fn from(value: Vec<String>) -> Self {
109        Stop::StringArray(value)
110    }
111}
112
113impl From<Vec<u32>> for Stop {
114    fn from(value: Vec<u32>) -> Self {
115        Stop::TokenIdArray(value)
116    }
117}
118
119impl From<async_openai::types::chat::StopConfiguration> for Stop {
120    fn from(value: async_openai::types::chat::StopConfiguration) -> Self {
121        match value {
122            async_openai::types::chat::StopConfiguration::String(value) => Stop::String(value),
123            async_openai::types::chat::StopConfiguration::StringArray(value) => {
124                Stop::StringArray(value)
125            }
126        }
127    }
128}
129
130// Upstream renamed FinishReason (streaming) -- re-export
131pub use async_openai::types::chat::FinishReason;
132
133// Upstream uses FunctionType where we used ChatCompletionToolType.
134// Re-export both names for compatibility.
135pub use async_openai::types::chat::FunctionType;
136
137/// Reasoning effort values accepted by OpenAI-compatible clients.
138///
139/// async-openai versions used by some Dynamo builds do not include `max`, but
140/// DeepSeek-V4 compatible clients may send it by default. Keep this local enum
141/// wire-compatible with upstream values and include `max`.
142#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "lowercase")]
144#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
145#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ReasoningEffort))]
146pub enum ReasoningEffort {
147    None,
148    Minimal,
149    Low,
150    Medium,
151    High,
152    Xhigh,
153    Max,
154}
155
156impl From<async_openai::types::chat::ReasoningEffort> for ReasoningEffort {
157    fn from(value: async_openai::types::chat::ReasoningEffort) -> Self {
158        match value {
159            async_openai::types::chat::ReasoningEffort::None => ReasoningEffort::None,
160            async_openai::types::chat::ReasoningEffort::Minimal => ReasoningEffort::Minimal,
161            async_openai::types::chat::ReasoningEffort::Low => ReasoningEffort::Low,
162            async_openai::types::chat::ReasoningEffort::Medium => ReasoningEffort::Medium,
163            async_openai::types::chat::ReasoningEffort::High => ReasoningEffort::High,
164            async_openai::types::chat::ReasoningEffort::Xhigh => ReasoningEffort::Xhigh,
165            async_openai::types::chat::ReasoningEffort::Max => ReasoningEffort::Max,
166        }
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Flexible `arguments` deserialisation helpers
172// ---------------------------------------------------------------------------
173// Some agent frameworks (e.g. LangChain, custom harnesses) send tool-call
174// arguments as a pre-parsed JSON object instead of the canonical JSON
175// string.  The helpers below normalise both representations to a `String` so
176// downstream code never needs to branch on the wire format.
177
178fn deserialize_arguments<'de, D>(deserializer: D) -> Result<String, D::Error>
179where
180    D: serde::Deserializer<'de>,
181{
182    use serde::de::Error;
183    let value = serde_json::Value::deserialize(deserializer)?;
184    match value {
185        serde_json::Value::String(s) => Ok(s),
186        v @ serde_json::Value::Object(_) => {
187            // serde_json::to_string on a Value is infallible
188            Ok(serde_json::to_string(&v).unwrap())
189        }
190        other => Err(D::Error::custom(format!(
191            "expected string or object for `arguments`, got {other}"
192        ))),
193    }
194}
195
196fn deserialize_arguments_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
197where
198    D: serde::Deserializer<'de>,
199{
200    use serde::de::Error;
201    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
202    match value {
203        None => Ok(None),
204        Some(serde_json::Value::String(s)) => Ok(Some(s)),
205        Some(v @ serde_json::Value::Object(_)) => serde_json::to_string(&v)
206            .map(Some)
207            .map_err(|e| D::Error::custom(e.to_string())),
208        Some(other) => Err(D::Error::custom(format!(
209            "expected string or object for `arguments`, got {other}"
210        ))),
211    }
212}
213
214/// Deserializes an optional media object, treating `{"url": ""}` as absent.
215///
216/// vLLM's OpenAI-compatible schema requires the media object to be present, so
217/// UUID-cache clients emit an empty URL where Dynamo's canonical form is `null`.
218/// Normalizing at the type boundary leaves `(url, uuid)` validation to consumers.
219fn deserialize_optional_media<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
220where
221    D: serde::Deserializer<'de>,
222    T: serde::de::DeserializeOwned,
223{
224    use serde::de::Error;
225    match Option::<serde_json::Value>::deserialize(deserializer)? {
226        None => Ok(None),
227        Some(value) if value.get("url").and_then(serde_json::Value::as_str) == Some("") => Ok(None),
228        Some(value) => serde_json::from_value(value)
229            .map(Some)
230            .map_err(D::Error::custom),
231    }
232}
233
234// ---------------------------------------------------------------------------
235// FunctionCall / FunctionCallStream — local definitions with flexible deser
236// ---------------------------------------------------------------------------
237// Upstream `async-openai` only accepts a JSON string for `arguments`.
238// We define these locally so we can attach `#[serde(deserialize_with)]` and
239// accept both string and object representations on the wire.
240
241/// The name and arguments of a function that should be called.
242///
243/// Accepts `arguments` as either a JSON string (`"{\"key\":\"value\"}"`) or a
244/// JSON object (`{"key": "value"}`); both are normalised to a JSON string
245/// on deserialisation so callers always see the canonical form.
246#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
247#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
248#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::FunctionCall))]
249pub struct FunctionCall {
250    pub name: String,
251    #[serde(deserialize_with = "deserialize_arguments")]
252    pub arguments: String,
253}
254
255/// Streaming variant of [`FunctionCall`] where both fields are optional.
256/// Continuation chunks carry only `arguments`; `name` is omitted rather
257/// than serialized as `null`, matching OpenAI output.
258#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
259#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
260#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::FunctionCallStream))]
261pub struct FunctionCallStream {
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub name: Option<String>,
264    #[serde(
265        default,
266        skip_serializing_if = "Option::is_none",
267        deserialize_with = "deserialize_arguments_opt"
268    )]
269    pub arguments: Option<String>,
270}
271
272/// Streaming tool-call chunk.
273///
274/// Defined locally (instead of re-exporting from upstream) because its
275/// `function` field references our local [`FunctionCallStream`] with the
276/// flexible `arguments` deserialiser.
277#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
278#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
279#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionMessageToolCallChunk))]
280pub struct ChatCompletionMessageToolCallChunk {
281    pub index: u32,
282    /// Only `index` is required by the spec; `id`, `type`, and `function`
283    /// are omitted on continuation chunks, matching OpenAI output.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub id: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::FunctionType>))]
288    pub r#type: Option<FunctionType>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub function: Option<FunctionCallStream>,
291}
292
293// ---------------------------------------------------------------------------
294// Types with structural differences from upstream (kept locally)
295// ---------------------------------------------------------------------------
296
297/// Image content part.
298///
299/// vLLM's OpenAI-compatible server accepts an optional top-level `uuid` on the
300/// media content part. For cache-hit-only requests, `uuid` carries the cache
301/// key and the canonical `image_url` is null. Clients constrained by vLLM's
302/// request schema may instead send `{"url": ""}`, which deserializes to the
303/// same representation. This is a vLLM extension, not part of the OpenAI Chat
304/// Completions API.
305#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
306#[builder(name = "ChatCompletionRequestMessageContentPartImageArgs")]
307#[builder(pattern = "mutable")]
308#[builder(setter(into, strip_option))]
309#[builder(derive(Debug))]
310#[builder(build_fn(error = "OpenAIError"))]
311#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
312#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessageContentPartImage))]
313pub struct ChatCompletionRequestMessageContentPartImage {
314    #[builder(default)]
315    #[serde(default, deserialize_with = "deserialize_optional_media")]
316    pub image_url: Option<ImageUrl>,
317    #[builder(default)]
318    #[serde(skip_serializing_if = "Option::is_none")]
319    /// vLLM-only multimodal processor-cache identity.
320    pub uuid: Option<String>,
321}
322
323/// Image URL with `url::Url` type and a legacy optional UUID.
324///
325/// New callers should put vLLM processor-cache identities on
326/// [`ChatCompletionRequestMessageContentPartImage::uuid`].
327#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
328#[builder(name = "ImageUrlArgs")]
329#[builder(pattern = "mutable")]
330#[builder(setter(into, strip_option))]
331#[builder(derive(Debug))]
332#[builder(build_fn(error = "OpenAIError"))]
333#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
334#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ImageUrl))]
335pub struct ImageUrl {
336    pub url: Url,
337    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ImageDetail>))]
338    pub detail: Option<ImageDetail>,
339    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub uuid: Option<Uuid>,
342}
343
344/// Tool message content part with media observation support.
345///
346/// OpenAI's schema currently limits tool content parts to text, but
347/// OpenAI-compatible multimodal backends also accept image, video, and audio
348/// observations returned by tools.
349#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
350#[serde(tag = "type")]
351#[serde(rename_all = "snake_case")]
352#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
353#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestToolMessageContentPart))]
354pub enum ChatCompletionRequestToolMessageContentPart {
355    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestMessageContentPartText))]
356    Text(ChatCompletionRequestMessageContentPartText),
357    ImageUrl(ChatCompletionRequestMessageContentPartImage),
358    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
359    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
360}
361
362/// Tool message content, extended to preserve media observations.
363#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
364#[serde(untagged)]
365#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
366#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestToolMessageContent))]
367pub enum ChatCompletionRequestToolMessageContent {
368    Text(String),
369    Array(Vec<ChatCompletionRequestToolMessageContentPart>),
370}
371
372impl Default for ChatCompletionRequestToolMessageContent {
373    fn default() -> Self {
374        Self::Text(String::new())
375    }
376}
377
378impl From<&str> for ChatCompletionRequestToolMessageContent {
379    fn from(value: &str) -> Self {
380        Self::Text(value.into())
381    }
382}
383
384impl From<String> for ChatCompletionRequestToolMessageContent {
385    fn from(value: String) -> Self {
386        Self::Text(value)
387    }
388}
389
390/// Tool message using Dynamo's media-capable content type.
391#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
392#[builder(name = "ChatCompletionRequestToolMessageArgs")]
393#[builder(pattern = "mutable")]
394#[builder(setter(into, strip_option), default)]
395#[builder(derive(Debug))]
396#[builder(build_fn(error = "OpenAIError"))]
397#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
398#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestToolMessage))]
399pub struct ChatCompletionRequestToolMessage {
400    pub content: ChatCompletionRequestToolMessageContent,
401    pub tool_call_id: String,
402}
403
404#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
405#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
406#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatChoiceLogprobs))]
407pub struct ChatChoiceLogprobs {
408    #[cfg_attr(feature = "protocol-schema", schema(required))]
409    pub content: Option<Vec<ChatCompletionTokenLogprob>>,
410    #[cfg_attr(feature = "protocol-schema", schema(required))]
411    pub refusal: Option<Vec<ChatCompletionTokenLogprob>>,
412}
413
414/// Token logprob entry with optional backend token ID.
415///
416/// Some inference backends can report both the rendered token string and its
417/// vocabulary ID. Keeping this optional preserves the upstream OpenAI shape
418/// when token IDs are unavailable.
419#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
420#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
421#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionTokenLogprob))]
422pub struct ChatCompletionTokenLogprob {
423    pub token: String,
424    pub logprob: f32,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub token_id: Option<u32>,
427    #[cfg_attr(feature = "protocol-schema", schema(required))]
428    pub bytes: Option<Vec<u8>>,
429    #[cfg_attr(feature = "protocol-schema", schema(value_type = Vec<crate::schema::TopLogprobs>))]
430    pub top_logprobs: Vec<TopLogprobs>,
431}
432
433#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
434#[serde(rename_all = "lowercase")]
435#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
436#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionToolType))]
437pub enum ChatCompletionToolType {
438    #[default]
439    Function,
440}
441
442#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
443#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
444#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::FunctionName))]
445pub struct FunctionName {
446    pub name: String,
447}
448
449#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
450#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
451#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionNamedToolChoice))]
452pub struct ChatCompletionNamedToolChoice {
453    pub r#type: ChatCompletionToolType,
454    pub function: FunctionName,
455}
456
457fn default_function_type() -> FunctionType {
458    FunctionType::Function
459}
460
461/// Tool call kept locally to preserve `type: "function"` in unary request/response payloads.
462///
463/// Differs from upstream: `type` is serialized by default and also defaults to
464/// `function` when omitted during deserialization, preserving compatibility with
465/// both Dynamo's historical wire format and upstream spec-compliant inputs.
466#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
467#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
468#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionMessageToolCall))]
469pub struct ChatCompletionMessageToolCall {
470    pub id: String,
471    #[serde(default = "default_function_type")]
472    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::FunctionType))]
473    pub r#type: FunctionType,
474    pub function: FunctionCall,
475}
476
477/// Tool choice enum kept locally because upstream changed variant names.
478#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
479#[serde(rename_all = "lowercase")]
480pub enum ChatCompletionToolChoiceOption {
481    #[default]
482    None,
483    Auto,
484    Required,
485    #[serde(untagged)]
486    Named(ChatCompletionNamedToolChoice),
487}
488
489// utoipa's derive does not honor per-variant serde(untagged). The named
490// choice is an object directly, not {"Named": {...}}.
491#[cfg(feature = "protocol-schema")]
492impl utoipa::PartialSchema for ChatCompletionToolChoiceOption {
493    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
494        use utoipa::openapi::schema::{AnyOfBuilder, ObjectBuilder, Type};
495        AnyOfBuilder::new()
496            .item(
497                ObjectBuilder::new()
498                    .schema_type(Type::String)
499                    .enum_values(Some(["none", "auto", "required"])),
500            )
501            .item(ChatCompletionNamedToolChoice::schema())
502            .into()
503    }
504}
505
506#[cfg(feature = "protocol-schema")]
507impl utoipa::ToSchema for ChatCompletionToolChoiceOption {
508    fn name() -> std::borrow::Cow<'static, str> {
509        "dynamo_protocols.chat.ChatCompletionToolChoiceOption".into()
510    }
511
512    fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr<utoipa::openapi::Schema>)>) {
513        ChatCompletionNamedToolChoice::schemas(schemas);
514    }
515}
516
517#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
518#[builder(name = "ChatCompletionToolArgs")]
519#[builder(pattern = "mutable")]
520#[builder(setter(into, strip_option), default)]
521#[builder(derive(Debug))]
522#[builder(build_fn(error = "OpenAIError"))]
523#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
524#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionTool))]
525pub struct ChatCompletionTool {
526    #[builder(default = "ChatCompletionToolType::Function")]
527    pub r#type: ChatCompletionToolType,
528    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::FunctionObject))]
529    pub function: FunctionObject,
530}
531
532// ---------------------------------------------------------------------------
533// Inference-serving extensions (not in upstream)
534// ---------------------------------------------------------------------------
535
536/// Matched stop condition from the backend.
537///
538/// Inference backends (vLLM, SGLang) report which stop condition triggered:
539/// - `String`: a matched user-provided stop sequence
540/// - `Int`: a matched stop token ID
541/// - `IntArray`: matched stop token IDs reported as a sequence
542#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
543#[serde(untagged)]
544#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
545#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::StopReason))]
546pub enum StopReason {
547    String(String),
548    Int(i64),
549    IntArray(Vec<i64>),
550}
551
552/// Reasoning content from a previous assistant turn.
553///
554/// Deserializes from either:
555/// - A plain string: `"reasoning_content": "thinking..."` -> `Text("thinking...")`
556/// - An array of strings: `"reasoning_content": ["seg1", "seg2"]` -> `Segments(["seg1", "seg2"])`
557///
558/// The `Segments` variant preserves interleaved reasoning order needed for KV cache-correct
559/// context reconstruction. `segments[i]` is the reasoning that preceded `tool_calls[i]`;
560/// `segments[tool_calls.len()]` is any trailing reasoning after the last tool call.
561#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
562#[serde(untagged)]
563#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
564#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ReasoningContent))]
565pub enum ReasoningContent {
566    /// Flat string -- single reasoning block or legacy backward-compat form.
567    Text(String),
568    /// Interleaved segments. segments[i] precedes tool_calls[i];
569    /// segments[N] is trailing reasoning after the last tool call.
570    Segments(Vec<String>),
571}
572
573impl ReasoningContent {
574    /// Join all segments (or return text as-is) into a single flat string.
575    pub fn to_flat_string(&self) -> String {
576        match self {
577            ReasoningContent::Text(s) => s.clone(),
578            ReasoningContent::Segments(segs) => segs
579                .iter()
580                .filter(|s| !s.is_empty())
581                .cloned()
582                .collect::<Vec<_>>()
583                .join("\n"),
584        }
585    }
586
587    /// Returns the segments if this is the `Segments` variant, `None` for `Text`.
588    pub fn segments(&self) -> Option<&[String]> {
589        match self {
590            ReasoningContent::Segments(segs) => Some(segs),
591            ReasoningContent::Text(_) => None,
592        }
593    }
594}
595
596// -- Multimodal content types for responses (not in upstream) --
597
598/// Response content part for text in assistant messages
599#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
600#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
601#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartText))]
602pub struct ChatCompletionResponseContentPartText {
603    pub text: String,
604}
605
606/// Response content part for image URLs in assistant messages
607#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
608#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
609#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartImageUrl))]
610pub struct ChatCompletionResponseContentPartImageUrl {
611    pub image_url: ImageUrlResponse,
612}
613
614/// Response content part for video URLs in assistant messages
615#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
616#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
617#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartVideoUrl))]
618pub struct ChatCompletionResponseContentPartVideoUrl {
619    pub video_url: VideoUrlResponse,
620}
621
622/// Response content part for audio URLs in assistant messages
623#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
624#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
625#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartAudioUrl))]
626pub struct ChatCompletionResponseContentPartAudioUrl {
627    pub audio_url: AudioUrlResponse,
628}
629
630#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
631#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
632#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ImageUrlResponse))]
633pub struct ImageUrlResponse {
634    pub url: String,
635    #[serde(skip_serializing_if = "Option::is_none")]
636    pub detail: Option<String>,
637}
638
639#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
640#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
641#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::VideoUrlResponse))]
642pub struct VideoUrlResponse {
643    pub url: String,
644}
645
646#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
647#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
648#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::AudioUrlResponse))]
649pub struct AudioUrlResponse {
650    pub url: String,
651}
652
653/// Content parts for assistant responses supporting multiple modalities
654#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
655#[serde(tag = "type", rename_all = "snake_case")]
656#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
657#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPart))]
658pub enum ChatCompletionResponseContentPart {
659    Text(ChatCompletionResponseContentPartText),
660    ImageUrl(ChatCompletionResponseContentPartImageUrl),
661    VideoUrl(ChatCompletionResponseContentPartVideoUrl),
662    AudioUrl(ChatCompletionResponseContentPartAudioUrl),
663}
664
665/// Assistant message content -- can be a simple string or multimodal content parts.
666///
667/// Upstream uses `Option<String>` for the content field. We extend this to
668/// support multimodal responses (text + images + video + audio) from backends
669/// like vLLM that can return non-text content.
670#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
671#[serde(untagged)]
672#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
673#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionMessageContent))]
674pub enum ChatCompletionMessageContent {
675    /// Simple text content (backward compatible)
676    Text(String),
677    /// Array of content parts (for multimodal responses)
678    Parts(Vec<ChatCompletionResponseContentPart>),
679}
680
681// -- Multimodal input types (video/audio URL support, not in upstream) --
682
683#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
684#[builder(name = "VideoUrlArgs")]
685#[builder(pattern = "mutable")]
686#[builder(setter(into, strip_option))]
687#[builder(derive(Debug))]
688#[builder(build_fn(error = "OpenAIError"))]
689#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
690#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::VideoUrl))]
691pub struct VideoUrl {
692    pub url: Url,
693    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ImageDetail>))]
694    pub detail: Option<ImageDetail>,
695    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub uuid: Option<Uuid>,
698}
699
700#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
701#[builder(name = "ChatCompletionRequestMessageContentPartVideoArgs")]
702#[builder(pattern = "mutable")]
703#[builder(setter(into, strip_option))]
704#[builder(derive(Debug))]
705#[builder(build_fn(error = "OpenAIError"))]
706#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
707#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessageContentPartVideo))]
708pub struct ChatCompletionRequestMessageContentPartVideo {
709    #[builder(default)]
710    #[serde(default, deserialize_with = "deserialize_optional_media")]
711    pub video_url: Option<VideoUrl>,
712    #[builder(default)]
713    #[serde(skip_serializing_if = "Option::is_none")]
714    /// vLLM-only multimodal processor-cache identity.
715    pub uuid: Option<String>,
716}
717
718#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
719#[builder(name = "AudioUrlArgs")]
720#[builder(pattern = "mutable")]
721#[builder(setter(into, strip_option))]
722#[builder(derive(Debug))]
723#[builder(build_fn(error = "OpenAIError"))]
724#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
725#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::AudioUrl))]
726pub struct AudioUrl {
727    pub url: Url,
728    #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
729    #[serde(skip_serializing_if = "Option::is_none")]
730    pub uuid: Option<Uuid>,
731}
732
733#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
734#[builder(name = "ChatCompletionRequestMessageContentPartAudioUrlArgs")]
735#[builder(pattern = "mutable")]
736#[builder(setter(into, strip_option))]
737#[builder(derive(Debug))]
738#[builder(build_fn(error = "OpenAIError"))]
739#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
740#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessageContentPartAudioUrl))]
741pub struct ChatCompletionRequestMessageContentPartAudioUrl {
742    #[builder(default)]
743    #[serde(default, deserialize_with = "deserialize_optional_media")]
744    pub audio_url: Option<AudioUrl>,
745    #[builder(default)]
746    #[serde(skip_serializing_if = "Option::is_none")]
747    /// vLLM-only multimodal processor-cache identity.
748    pub uuid: Option<String>,
749}
750
751// -- Extended request/response types --
752
753/// User message content -- references our extended content part enum.
754#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
755#[serde(untagged)]
756#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
757#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestUserMessageContent))]
758pub enum ChatCompletionRequestUserMessageContent {
759    Text(String),
760    Array(Vec<ChatCompletionRequestUserMessageContentPart>),
761}
762
763#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
764#[builder(name = "ChatCompletionRequestUserMessageArgs")]
765#[builder(pattern = "mutable")]
766#[builder(setter(into, strip_option), default)]
767#[builder(derive(Debug))]
768#[builder(build_fn(error = "OpenAIError"))]
769#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
770#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestUserMessage))]
771pub struct ChatCompletionRequestUserMessage {
772    pub content: ChatCompletionRequestUserMessageContent,
773    #[serde(skip_serializing_if = "Option::is_none")]
774    pub name: Option<String>,
775}
776
777impl Default for ChatCompletionRequestUserMessageContent {
778    fn default() -> Self {
779        Self::Text(String::new())
780    }
781}
782
783impl From<&str> for ChatCompletionRequestUserMessageContent {
784    fn from(value: &str) -> Self {
785        Self::Text(value.into())
786    }
787}
788
789impl From<String> for ChatCompletionRequestUserMessageContent {
790    fn from(value: String) -> Self {
791        Self::Text(value)
792    }
793}
794
795impl From<Vec<ChatCompletionRequestUserMessageContentPart>>
796    for ChatCompletionRequestUserMessageContent
797{
798    fn from(value: Vec<ChatCompletionRequestUserMessageContentPart>) -> Self {
799        Self::Array(value)
800    }
801}
802
803/// User message content part with video and audio URL support.
804///
805/// Extends upstream `ChatCompletionRequestUserMessageContentPart` with:
806/// - `VideoUrl`: video input for multimodal models
807/// - `AudioUrl`: audio URL input (distinct from base64 InputAudio)
808#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
809#[serde(tag = "type")]
810#[serde(rename_all = "snake_case")]
811#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
812#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestUserMessageContentPart))]
813pub enum ChatCompletionRequestUserMessageContentPart {
814    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestMessageContentPartText))]
815    Text(ChatCompletionRequestMessageContentPartText),
816    ImageUrl(ChatCompletionRequestMessageContentPartImage),
817    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
818    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
819    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestMessageContentPartAudio))]
820    InputAudio(ChatCompletionRequestMessageContentPartAudio),
821}
822
823/// System message with dynamic tool metadata support.
824///
825/// Extends upstream `ChatCompletionRequestSystemMessage` with:
826/// - `content`: still required in the public Rust type. On the wire only,
827///   Kimi-style messages may omit it (or send `null`) when they declare
828///   non-empty `tools`; deserialization canonicalizes that shape to empty text.
829///   Every other content-less system message is still rejected with upstream's
830///   `missing field \`content\`` error, so spec-conformant clients and non-Kimi
831///   models see no behavior change. Without this guard a bare
832///   `{"role": "system"}` would reach ordinary HF jinja templates and render
833///   an empty system turn instead of failing the request.
834/// - `tools`: passthrough field for model-specific tool metadata rendered by the
835///   chat template. Dynamo does not interpret this field; it is preserved
836///   verbatim for downstream chat-template rendering.
837///
838/// `Default` (and therefore the builder's unset state) uses empty-string
839/// `content`, matching upstream. Keeping `content` non-optional also prevents
840/// programmatic callers from constructing a content-less, tool-less message.
841#[derive(Debug, Serialize, Clone, Builder, PartialEq, Default)]
842#[builder(name = "ChatCompletionRequestSystemMessageArgs")]
843#[builder(pattern = "mutable")]
844#[builder(setter(into, strip_option), default)]
845#[builder(derive(Debug))]
846#[builder(build_fn(error = "OpenAIError"))]
847#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
848#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestSystemMessage))]
849pub struct ChatCompletionRequestSystemMessage {
850    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestSystemMessageContent))]
851    pub content: ChatCompletionRequestSystemMessageContent,
852    #[serde(skip_serializing_if = "Option::is_none")]
853    pub name: Option<String>,
854    /// Kimi-style dynamic tool metadata carried on a system message.
855    ///
856    /// Moonshot treats omitted, null, and empty `content` as no system text;
857    /// renderers enforce that non-empty `content` and `tools` are mutually
858    /// exclusive and that `tools` is non-empty. The list shape is typed here
859    /// so non-array values are rejected at deserialization.
860    ///
861    /// Entries stay raw JSON rather than a typed schema on purpose: this crate
862    /// only needs to *preserve* them for downstream chat-template rendering,
863    /// which reads them back as generic JSON by key. A typed entry (e.g.
864    /// `FunctionObject`) would silently drop vendor-specific keys serde doesn't
865    /// know about on round-trip, whereas `serde_json::Value` is structurally
866    /// lossless (JSON structure and unknown keys survive; whitespace, number
867    /// spelling, and duplicate keys do not).
868    ///
869    /// Kimi's `encoding_k3.py` renders this through the same tool-declare path
870    /// as the top-level `tools` field and never inspects individual entries, so
871    /// the canonical shape is the same OpenAI wrapped form,
872    /// `{"type": "function", "function": {...}}`. Clients that send bare
873    /// function-schema objects (`{"name": ..., "parameters": ...}`) are passed
874    /// through unchanged as well; this crate takes no position on the shape.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub tools: Option<Vec<serde_json::Value>>,
877}
878
879impl<'de> Deserialize<'de> for ChatCompletionRequestSystemMessage {
880    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
881    where
882        D: serde::Deserializer<'de>,
883    {
884        use serde::de::Error;
885
886        /// Wire shape with `content` relaxed solely for recognizing Kimi's
887        /// tools-only form. Deserialized first so field-level errors (bad
888        /// `content` shape, non-array `tools`) keep serde's own messages.
889        #[derive(Deserialize)]
890        struct Wire {
891            content: Option<ChatCompletionRequestSystemMessageContent>,
892            name: Option<String>,
893            tools: Option<Vec<serde_json::Value>>,
894        }
895
896        let Wire {
897            content,
898            name,
899            tools,
900        } = Wire::deserialize(deserializer)?;
901        let content = match content {
902            Some(content) => content,
903            None if tools.as_ref().is_some_and(|tools| !tools.is_empty()) => {
904                ChatCompletionRequestSystemMessageContent::Text(String::new())
905            }
906            None => {
907                return Err(D::Error::custom(
908                    "missing field `content`: a system message needs `content` unless it \
909                     declares non-empty Kimi-style `tools`",
910                ));
911            }
912        };
913        Ok(Self {
914            content,
915            name,
916            tools,
917        })
918    }
919}
920
921/// Assistant message with reasoning content support.
922///
923/// Extends upstream `ChatCompletionRequestAssistantMessage` with:
924/// - `reasoning_content`: interleaved reasoning segments for KV cache correctness
925///   (DeepSeek-R1, QwQ models)
926/// - `partial`: Kimi-style prefill flag marking an assistant turn as an
927///   incomplete continuation seed rather than a finished turn
928#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
929#[builder(name = "ChatCompletionRequestAssistantMessageArgs")]
930#[builder(pattern = "mutable")]
931#[builder(setter(into, strip_option), default)]
932#[builder(derive(Debug))]
933#[builder(build_fn(error = "OpenAIError"))]
934#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
935#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestAssistantMessage))]
936pub struct ChatCompletionRequestAssistantMessage {
937    #[serde(skip_serializing_if = "Option::is_none")]
938    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionRequestAssistantMessageContent>))]
939    pub content: Option<ChatCompletionRequestAssistantMessageContent>,
940    /// Reasoning content from a previous assistant turn.
941    /// Accept both `reasoning_content` (DeepSeek /
942    /// SGLang / TRT-LLM / Vercel AI SDK openai-compatible / LangChain / LiteLLM
943    /// canonical) and `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS
944    /// guidance) on inbound assistant messages, normalizing both to this field.
945    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
946    pub reasoning_content: Option<ReasoningContent>,
947    #[serde(skip_serializing_if = "Option::is_none")]
948    pub refusal: Option<String>,
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub name: Option<String>,
951    #[serde(skip_serializing_if = "Option::is_none")]
952    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionRequestAssistantMessageAudio>))]
953    pub audio: Option<ChatCompletionRequestAssistantMessageAudio>,
954    #[serde(skip_serializing_if = "Option::is_none")]
955    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
956    #[deprecated]
957    #[serde(skip_serializing_if = "Option::is_none")]
958    pub function_call: Option<FunctionCall>,
959    #[serde(skip_serializing_if = "Option::is_none")]
960    pub partial: Option<bool>,
961}
962
963/// Chat completion request message enum.
964///
965/// Redefined to use our extended `ChatCompletionRequestAssistantMessage`
966/// (with reasoning_content) and `ChatCompletionRequestUserMessage`
967/// (which references our extended content parts with video/audio).
968///
969/// Deserialization rejects Kimi-specific fields on roles that cannot carry
970/// them (`tools` off `system`, `partial` off `assistant`) instead of letting
971/// serde's ignore-unknown-fields default drop them silently; Moonshot's
972/// negative tests expect a request error for these shapes.
973#[derive(Debug, Serialize, Clone, PartialEq)]
974#[serde(tag = "role")]
975#[serde(rename_all = "lowercase")]
976#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
977#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessage))]
978pub enum ChatCompletionRequestMessage {
979    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestDeveloperMessage))]
980    Developer(ChatCompletionRequestDeveloperMessage),
981    System(ChatCompletionRequestSystemMessage),
982    User(ChatCompletionRequestUserMessage),
983    Assistant(ChatCompletionRequestAssistantMessage),
984    Tool(ChatCompletionRequestToolMessage),
985    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestFunctionMessage))]
986    Function(ChatCompletionRequestFunctionMessage),
987}
988
989impl<'de> Deserialize<'de> for ChatCompletionRequestMessage {
990    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
991    where
992        D: serde::Deserializer<'de>,
993    {
994        use serde::de::Error;
995
996        #[derive(Deserialize)]
997        struct ForbidToolsAndPartial<T> {
998            tools: Option<serde::de::IgnoredAny>,
999            partial: Option<serde::de::IgnoredAny>,
1000            #[serde(flatten)]
1001            message: T,
1002        }
1003
1004        #[derive(Deserialize)]
1005        struct ForbidTools<T> {
1006            tools: Option<serde::de::IgnoredAny>,
1007            #[serde(flatten)]
1008            message: T,
1009        }
1010
1011        #[derive(Deserialize)]
1012        struct ForbidPartial<T> {
1013            partial: Option<serde::de::IgnoredAny>,
1014            #[serde(flatten)]
1015            message: T,
1016        }
1017
1018        #[derive(Deserialize)]
1019        #[serde(tag = "role")]
1020        #[serde(rename_all = "lowercase")]
1021        enum Wire {
1022            Developer(ForbidToolsAndPartial<ChatCompletionRequestDeveloperMessage>),
1023            System(ForbidPartial<ChatCompletionRequestSystemMessage>),
1024            User(ForbidToolsAndPartial<ChatCompletionRequestUserMessage>),
1025            Assistant(ForbidTools<ChatCompletionRequestAssistantMessage>),
1026            Tool(ForbidToolsAndPartial<ChatCompletionRequestToolMessage>),
1027            Function(ForbidToolsAndPartial<ChatCompletionRequestFunctionMessage>),
1028        }
1029
1030        fn reject_forbidden<E: Error>(
1031            value: Option<serde::de::IgnoredAny>,
1032            field: &str,
1033            allowed_role: &str,
1034            actual_role: &str,
1035        ) -> Result<(), E> {
1036            if value.is_some() {
1037                return Err(E::custom(format!(
1038                    "`{field}` is only accepted on {allowed_role} messages, not on role {actual_role}"
1039                )));
1040            }
1041            Ok(())
1042        }
1043
1044        let wire = Wire::deserialize(deserializer)?;
1045        Ok(match wire {
1046            Wire::Developer(ForbidToolsAndPartial {
1047                tools,
1048                partial,
1049                message,
1050            }) => {
1051                reject_forbidden::<D::Error>(tools, "tools", "system", "developer")?;
1052                reject_forbidden::<D::Error>(partial, "partial", "assistant", "developer")?;
1053                ChatCompletionRequestMessage::Developer(message)
1054            }
1055            Wire::System(ForbidPartial { partial, message }) => {
1056                reject_forbidden::<D::Error>(partial, "partial", "assistant", "system")?;
1057                ChatCompletionRequestMessage::System(message)
1058            }
1059            Wire::User(ForbidToolsAndPartial {
1060                tools,
1061                partial,
1062                message,
1063            }) => {
1064                reject_forbidden::<D::Error>(tools, "tools", "system", "user")?;
1065                reject_forbidden::<D::Error>(partial, "partial", "assistant", "user")?;
1066                ChatCompletionRequestMessage::User(message)
1067            }
1068            Wire::Assistant(ForbidTools { tools, message }) => {
1069                reject_forbidden::<D::Error>(tools, "tools", "system", "assistant")?;
1070                ChatCompletionRequestMessage::Assistant(message)
1071            }
1072            Wire::Tool(ForbidToolsAndPartial {
1073                tools,
1074                partial,
1075                message,
1076            }) => {
1077                reject_forbidden::<D::Error>(tools, "tools", "system", "tool")?;
1078                reject_forbidden::<D::Error>(partial, "partial", "assistant", "tool")?;
1079                ChatCompletionRequestMessage::Tool(message)
1080            }
1081            Wire::Function(ForbidToolsAndPartial {
1082                tools,
1083                partial,
1084                message,
1085            }) => {
1086                reject_forbidden::<D::Error>(tools, "tools", "system", "function")?;
1087                reject_forbidden::<D::Error>(partial, "partial", "assistant", "function")?;
1088                ChatCompletionRequestMessage::Function(message)
1089            }
1090        })
1091    }
1092}
1093
1094/// Backward-compatible name for the service tier reported in responses.
1095pub type ServiceTierResponse = ServiceTier;
1096
1097/// Chat completion response message with multimodal content and reasoning.
1098///
1099/// Extends upstream `ChatCompletionResponseMessage` with:
1100/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
1101/// - `reasoning_content`: model reasoning output (DeepSeek-R1, QwQ)
1102#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1103#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1104#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseMessage))]
1105pub struct ChatCompletionResponseMessage {
1106    /// Always serialized (as `null` when None) so clients can rely on the
1107    /// `content` key being present alongside `reasoning_content` or
1108    /// `tool_calls`. Matches the upstream OpenAI API shape (DGH-651).
1109    #[cfg_attr(feature = "protocol-schema", schema(required))]
1110    pub content: Option<ChatCompletionMessageContent>,
1111    /// Always serialized (as `null` when None): the spec marks `refusal` as
1112    /// required-and-nullable, and OpenAI emits `"refusal": null` on every
1113    /// non-refusal response.
1114    #[cfg_attr(feature = "protocol-schema", schema(required))]
1115    pub refusal: Option<String>,
1116    #[serde(skip_serializing_if = "Option::is_none")]
1117    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
1118    #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::Role))]
1119    pub role: Role,
1120    #[serde(skip_serializing_if = "Option::is_none")]
1121    #[deprecated]
1122    pub function_call: Option<FunctionCall>,
1123    #[serde(skip_serializing_if = "Option::is_none")]
1124    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionResponseMessageAudio>))]
1125    pub audio: Option<ChatCompletionResponseMessageAudio>,
1126    /// Reasoning content produced by the model (DeepSeek-R1, QwQ).
1127    /// Accepts either `reasoning_content` (DeepSeek / SGLang / TRT-LLM
1128    /// canonical) or `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS)
1129    /// on input via the alias; output-side key selection is handled at the
1130    /// HTTP boundary by ai-dynamo/dynamo#11464's `RoutedReasoning` wrapper.
1131    /// Not part of the OpenAI spec, so it is omitted entirely when absent.
1132    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
1133    pub reasoning_content: Option<String>,
1134}
1135
1136fn deserialize_null_as_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
1137where
1138    D: serde::Deserializer<'de>,
1139{
1140    Option::<bool>::deserialize(deserializer).map(Option::unwrap_or_default)
1141}
1142
1143/// Stream options with per-chunk usage reporting.
1144///
1145/// Extends upstream `ChatCompletionStreamOptions` with:
1146/// - `continuous_usage_stats`: emit usage in every chunk, not just the final one
1147#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
1148#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1149#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionStreamOptions))]
1150pub struct ChatCompletionStreamOptions {
1151    #[serde(default, deserialize_with = "deserialize_null_as_false")]
1152    pub include_usage: bool,
1153    /// When true, usage statistics are included in every streaming chunk.
1154    /// Backends like vLLM/SGLang support this for real-time token counting.
1155    #[serde(default, deserialize_with = "deserialize_null_as_false")]
1156    pub continuous_usage_stats: bool,
1157}
1158
1159/// Chat completion request with multimodal processor support.
1160///
1161/// Extends upstream `CreateChatCompletionRequest` with:
1162/// - `mm_processor_kwargs`: multimodal processor configuration (vLLM-specific)
1163/// - Uses our extended `ChatCompletionRequestMessage` (with reasoning, video/audio)
1164/// - Uses our extended `ChatCompletionStreamOptions` (with continuous_usage_stats)
1165#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
1166#[builder(name = "CreateChatCompletionRequestArgs")]
1167#[builder(pattern = "mutable")]
1168#[builder(setter(into, strip_option), default)]
1169#[builder(derive(Debug))]
1170#[builder(build_fn(error = "OpenAIError"))]
1171#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1172#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::CreateChatCompletionRequest))]
1173pub struct CreateChatCompletionRequest {
1174    pub messages: Vec<ChatCompletionRequestMessage>,
1175    pub model: String,
1176    /// Multimodal processor configuration (vLLM-specific)
1177    #[serde(skip_serializing_if = "Option::is_none")]
1178    pub mm_processor_kwargs: Option<serde_json::Value>,
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    pub store: Option<bool>,
1181    #[serde(skip_serializing_if = "Option::is_none")]
1182    pub reasoning_effort: Option<ReasoningEffort>,
1183    #[serde(skip_serializing_if = "Option::is_none")]
1184    pub metadata: Option<serde_json::Value>,
1185    #[serde(skip_serializing_if = "Option::is_none")]
1186    pub frequency_penalty: Option<f32>,
1187    #[serde(skip_serializing_if = "Option::is_none")]
1188    pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
1189    #[serde(skip_serializing_if = "Option::is_none")]
1190    pub logprobs: Option<bool>,
1191    #[serde(skip_serializing_if = "Option::is_none")]
1192    pub top_logprobs: Option<u8>,
1193    #[deprecated]
1194    #[serde(skip_serializing_if = "Option::is_none")]
1195    pub max_tokens: Option<u32>,
1196    #[serde(skip_serializing_if = "Option::is_none")]
1197    pub max_completion_tokens: Option<u32>,
1198    #[serde(skip_serializing_if = "Option::is_none")]
1199    pub n: Option<u8>,
1200    #[serde(skip_serializing_if = "Option::is_none")]
1201    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<Vec<crate::schema::ResponseModalities>>))]
1202    pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
1203    #[serde(skip_serializing_if = "Option::is_none")]
1204    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::PredictionContent>))]
1205    pub prediction: Option<PredictionContent>,
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionAudio>))]
1208    pub audio: Option<ChatCompletionAudio>,
1209    #[serde(skip_serializing_if = "Option::is_none")]
1210    pub presence_penalty: Option<f32>,
1211    #[serde(skip_serializing_if = "Option::is_none")]
1212    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ResponseFormat>))]
1213    pub response_format: Option<ResponseFormat>,
1214    #[serde(skip_serializing_if = "Option::is_none")]
1215    pub seed: Option<i64>,
1216    #[serde(skip_serializing_if = "Option::is_none")]
1217    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ServiceTier>))]
1218    pub service_tier: Option<ServiceTier>,
1219    #[serde(skip_serializing_if = "Option::is_none")]
1220    pub stop: Option<Stop>,
1221    #[serde(default, skip_serializing_if = "Option::is_none")]
1222    pub stream: Option<bool>,
1223    #[serde(skip_serializing_if = "Option::is_none")]
1224    pub stream_options: Option<ChatCompletionStreamOptions>,
1225    #[serde(skip_serializing_if = "Option::is_none")]
1226    pub temperature: Option<f32>,
1227    #[serde(skip_serializing_if = "Option::is_none")]
1228    pub top_p: Option<f32>,
1229    #[serde(skip_serializing_if = "Option::is_none")]
1230    pub tools: Option<Vec<ChatCompletionTool>>,
1231    #[serde(skip_serializing_if = "Option::is_none")]
1232    pub tool_choice: Option<ChatCompletionToolChoiceOption>,
1233    #[serde(skip_serializing_if = "Option::is_none")]
1234    pub parallel_tool_calls: Option<bool>,
1235    #[serde(skip_serializing_if = "Option::is_none")]
1236    pub user: Option<String>,
1237    /// OpenAI cache-affinity hint: requests sharing a prompt prefix send the
1238    /// same key (Kimi Code CLI sends its session id on every request).
1239    ///
1240    /// NOTICE: accepted and preserved only. Nothing in this crate or in Dynamo
1241    /// acts on it yet — Dynamo's KV-aware router keys on prompt-prefix block
1242    /// hashes, not on this value.
1243    // TODO(routing): decide whether `prompt_cache_key` should feed router
1244    // affinity (e.g. as a tie-breaker or session pin) and plumb it through.
1245    #[serde(skip_serializing_if = "Option::is_none")]
1246    pub prompt_cache_key: Option<String>,
1247    #[deprecated]
1248    #[serde(skip_serializing_if = "Option::is_none")]
1249    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionFunctionCall>))]
1250    pub function_call: Option<ChatCompletionFunctionCall>,
1251    #[deprecated]
1252    #[serde(skip_serializing_if = "Option::is_none")]
1253    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<Vec<crate::schema::ChatCompletionFunctions>>))]
1254    pub functions: Option<Vec<ChatCompletionFunctions>>,
1255    #[serde(skip_serializing_if = "Option::is_none")]
1256    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::WebSearchOptions>))]
1257    pub web_search_options: Option<WebSearchOptions>,
1258}
1259
1260impl CreateChatCompletionRequest {
1261    /// Kimi-style dynamic tools declared on `system` messages, in message order.
1262    ///
1263    /// Kimi defines these as coexisting with the top-level `tools` list: a
1264    /// dynamic declaration keeps its position in the message history so the
1265    /// prompt prefix (and any KV cache built on it) stays intact. Do not fold
1266    /// them into `tools`; reason about the union with
1267    /// [`Self::has_effective_tools`] and [`Self::effective_tool_contains`].
1268    ///
1269    /// Only `system` messages can carry `tools` in the typed schema; the
1270    /// `developer` message is the upstream type and has no such field.
1271    pub fn dynamic_system_tools(&self) -> impl Iterator<Item = &serde_json::Value> {
1272        self.messages
1273            .iter()
1274            .filter_map(|message| match message {
1275                ChatCompletionRequestMessage::System(system) => system.tools.as_deref(),
1276                _ => None,
1277            })
1278            .flatten()
1279    }
1280
1281    /// Whether the request declares any tool, either top-level or through a
1282    /// dynamic system-message declaration.
1283    ///
1284    /// Gates that decide whether model output may be interpreted as tool
1285    /// calls must use this rather than `tools` alone, or a call to a
1286    /// dynamically declared tool is stripped from the response.
1287    pub fn has_effective_tools(&self) -> bool {
1288        self.tools.as_ref().is_some_and(|tools| !tools.is_empty())
1289            || self.dynamic_system_tools().next().is_some()
1290    }
1291
1292    /// Names of every tool the model can see: top-level `tools` first, then
1293    /// dynamic system-message tools in message order.
1294    pub fn effective_tool_names(&self) -> impl Iterator<Item = &str> {
1295        self.tools
1296            .iter()
1297            .flatten()
1298            .map(|tool| tool.function.name.as_str())
1299            .chain(self.dynamic_system_tools().filter_map(dynamic_tool_name))
1300    }
1301
1302    /// Whether `name` is declared anywhere in the effective tool set.
1303    ///
1304    /// Use this to validate a named `tool_choice` so a forced call to a
1305    /// dynamically declared tool is not rejected as "not present in tools".
1306    pub fn effective_tool_contains(&self, name: &str) -> bool {
1307        self.effective_tool_names().any(|tool| tool == name)
1308    }
1309}
1310
1311/// Name of a dynamic system-message tool entry.
1312///
1313/// Accepts both the OpenAI wrapped form
1314/// `{"type": "function", "function": {"name": ...}}` and the bare
1315/// function-schema form `{"name": ...}` that some Kimi clients send. Returns
1316/// `None` for entries with no string name.
1317pub fn dynamic_tool_name(tool: &serde_json::Value) -> Option<&str> {
1318    tool.get("function")
1319        .and_then(serde_json::Value::as_object)
1320        .and_then(|function| function.get("name"))
1321        .or_else(|| tool.get("name"))
1322        .and_then(serde_json::Value::as_str)
1323}
1324
1325/// Chat choice with extended response message.
1326///
1327/// Uses our `ChatCompletionResponseMessage` (multimodal content + reasoning).
1328#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1329#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1330#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatChoice))]
1331pub struct ChatChoice {
1332    pub index: u32,
1333    pub message: ChatCompletionResponseMessage,
1334    // These keys are always serialized, including explicit nulls.
1335    #[cfg_attr(feature = "protocol-schema", schema(required, value_type = Option<crate::schema::FinishReason>))]
1336    pub finish_reason: Option<FinishReason>,
1337    #[cfg_attr(feature = "protocol-schema", schema(required))]
1338    pub logprobs: Option<ChatChoiceLogprobs>,
1339}
1340
1341/// Serializes `usage` through a shadow struct that omits absent optional
1342/// fields.
1343///
1344/// Upstream async-openai derives serialize `None` usage-details fields as
1345/// explicit `null` (e.g. `"audio_tokens": null`), but the spec marks every
1346/// usage-details field optional and non-nullable, so absent fields must be
1347/// omitted (OpenAI emits `"audio_tokens": 0`, never `null`). The shadow
1348/// keeps upstream `CompletionUsage` in the public API — replacing it with a
1349/// same-named local type would break callers that pass upstream values.
1350fn serialize_usage_omitting_absent<S>(
1351    usage: &Option<CompletionUsage>,
1352    serializer: S,
1353) -> Result<S::Ok, S::Error>
1354where
1355    S: serde::Serializer,
1356{
1357    #[derive(Serialize)]
1358    struct PromptDetailsShadow {
1359        #[serde(skip_serializing_if = "Option::is_none")]
1360        audio_tokens: Option<u32>,
1361        #[serde(skip_serializing_if = "Option::is_none")]
1362        cached_tokens: Option<u32>,
1363    }
1364
1365    #[derive(Serialize)]
1366    struct CompletionDetailsShadow {
1367        #[serde(skip_serializing_if = "Option::is_none")]
1368        accepted_prediction_tokens: Option<u32>,
1369        #[serde(skip_serializing_if = "Option::is_none")]
1370        audio_tokens: Option<u32>,
1371        #[serde(skip_serializing_if = "Option::is_none")]
1372        reasoning_tokens: Option<u32>,
1373        #[serde(skip_serializing_if = "Option::is_none")]
1374        rejected_prediction_tokens: Option<u32>,
1375    }
1376
1377    #[derive(Serialize)]
1378    struct UsageShadow {
1379        prompt_tokens: u32,
1380        completion_tokens: u32,
1381        total_tokens: u32,
1382        #[serde(skip_serializing_if = "Option::is_none")]
1383        prompt_tokens_details: Option<PromptDetailsShadow>,
1384        #[serde(skip_serializing_if = "Option::is_none")]
1385        completion_tokens_details: Option<CompletionDetailsShadow>,
1386    }
1387
1388    match usage {
1389        None => serializer.serialize_none(),
1390        Some(u) => UsageShadow {
1391            prompt_tokens: u.prompt_tokens,
1392            completion_tokens: u.completion_tokens,
1393            total_tokens: u.total_tokens,
1394            prompt_tokens_details: u
1395                .prompt_tokens_details
1396                .as_ref()
1397                .map(|d| PromptDetailsShadow {
1398                    audio_tokens: d.audio_tokens,
1399                    cached_tokens: d.cached_tokens,
1400                }),
1401            completion_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
1402                CompletionDetailsShadow {
1403                    accepted_prediction_tokens: d.accepted_prediction_tokens,
1404                    audio_tokens: d.audio_tokens,
1405                    reasoning_tokens: d.reasoning_tokens,
1406                    rejected_prediction_tokens: d.rejected_prediction_tokens,
1407                }
1408            }),
1409        }
1410        .serialize(serializer),
1411    }
1412}
1413
1414/// Non-streaming chat completion response.
1415///
1416/// `service_tier`, `system_fingerprint`, and `usage` are optional in the
1417/// spec and omitted (not serialized as `null`) when absent, matching
1418/// OpenAI output. `choices[].finish_reason` and `choices[].logprobs` stay
1419/// always-present: the spec marks them required (nullable for `logprobs`).
1420#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1421#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1422#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::CreateChatCompletionResponse))]
1423pub struct CreateChatCompletionResponse {
1424    pub id: String,
1425    pub choices: Vec<ChatChoice>,
1426    pub created: u32,
1427    pub model: String,
1428    #[serde(skip_serializing_if = "Option::is_none")]
1429    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ServiceTier>))]
1430    pub service_tier: Option<ServiceTierResponse>,
1431    #[serde(skip_serializing_if = "Option::is_none")]
1432    pub system_fingerprint: Option<String>,
1433    pub object: String,
1434    #[serde(
1435        skip_serializing_if = "Option::is_none",
1436        serialize_with = "serialize_usage_omitting_absent"
1437    )]
1438    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::CompletionUsage>))]
1439    pub usage: Option<CompletionUsage>,
1440}
1441
1442pub type ChatCompletionResponseStream =
1443    Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
1444
1445/// Streaming delta with reasoning content.
1446///
1447/// Extends upstream `ChatCompletionStreamResponseDelta` with:
1448/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
1449/// - `reasoning_content`: streaming reasoning tokens (DeepSeek-R1, QwQ)
1450#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1451#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1452#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionStreamResponseDelta))]
1453pub struct ChatCompletionStreamResponseDelta {
1454    #[serde(skip_serializing_if = "Option::is_none")]
1455    pub content: Option<ChatCompletionMessageContent>,
1456    #[serde(skip_serializing_if = "Option::is_none")]
1457    pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
1458    #[serde(skip_serializing_if = "Option::is_none")]
1459    pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
1460    #[serde(skip_serializing_if = "Option::is_none")]
1461    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::Role>))]
1462    pub role: Option<Role>,
1463    #[serde(skip_serializing_if = "Option::is_none")]
1464    pub refusal: Option<String>,
1465    /// Streaming reasoning content (DeepSeek-R1, QwQ models).
1466    /// Accepts either `reasoning_content` (DeepSeek / SGLang / TRT-LLM
1467    /// canonical) or `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS)
1468    /// on input via the alias; output-side key selection is handled at the
1469    /// HTTP boundary by ai-dynamo/dynamo#11464's `RoutedReasoning` wrapper.
1470    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
1471    pub reasoning_content: Option<String>,
1472}
1473
1474#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1475#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1476#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionStreamResponseDeltaFunctionCall))]
1477pub struct ChatCompletionStreamResponseDeltaFunctionCall {
1478    #[serde(skip_serializing_if = "Option::is_none")]
1479    pub name: Option<String>,
1480    #[serde(
1481        default,
1482        deserialize_with = "deserialize_arguments_opt",
1483        skip_serializing_if = "Option::is_none"
1484    )]
1485    pub arguments: Option<String>,
1486}
1487
1488/// Streaming chat choice.
1489#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1490#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1491#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatChoiceStream))]
1492pub struct ChatChoiceStream {
1493    pub index: u32,
1494    pub delta: ChatCompletionStreamResponseDelta,
1495    // These keys are always serialized, including explicit nulls.
1496    #[cfg_attr(feature = "protocol-schema", schema(required, value_type = Option<crate::schema::FinishReason>))]
1497    pub finish_reason: Option<FinishReason>,
1498    #[cfg_attr(feature = "protocol-schema", schema(required))]
1499    pub logprobs: Option<ChatChoiceLogprobs>,
1500}
1501
1502/// Streaming chat completion response with extended choices.
1503///
1504/// `service_tier`, `system_fingerprint`, and `usage` are optional in the
1505/// spec and omitted (not serialized as `null`) when absent. Note: with
1506/// `stream_options.include_usage`, OpenAI emits `"usage": null` on every
1507/// chunk before the final one; callers needing that exact shape must
1508/// inject the key at the HTTP boundary.
1509#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1510#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1511#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::CreateChatCompletionStreamResponse))]
1512pub struct CreateChatCompletionStreamResponse {
1513    pub id: String,
1514    pub choices: Vec<ChatChoiceStream>,
1515    pub created: u32,
1516    pub model: String,
1517    #[serde(skip_serializing_if = "Option::is_none")]
1518    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ServiceTier>))]
1519    pub service_tier: Option<ServiceTierResponse>,
1520    #[serde(skip_serializing_if = "Option::is_none")]
1521    pub system_fingerprint: Option<String>,
1522    pub object: String,
1523    #[serde(
1524        skip_serializing_if = "Option::is_none",
1525        serialize_with = "serialize_usage_omitting_absent"
1526    )]
1527    #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::CompletionUsage>))]
1528    pub usage: Option<CompletionUsage>,
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533    use super::*;
1534
1535    #[test]
1536    fn stream_options_default_missing_and_null_flags_to_false() {
1537        for (payload, expected) in [
1538            (serde_json::json!({}), (false, false)),
1539            (
1540                serde_json::json!({
1541                    "include_usage": null,
1542                    "continuous_usage_stats": true,
1543                }),
1544                (false, true),
1545            ),
1546            (
1547                serde_json::json!({
1548                    "include_usage": true,
1549                    "continuous_usage_stats": null,
1550                }),
1551                (true, false),
1552            ),
1553        ] {
1554            let options: ChatCompletionStreamOptions = serde_json::from_value(payload).unwrap();
1555            assert_eq!(
1556                (options.include_usage, options.continuous_usage_stats),
1557                expected
1558            );
1559        }
1560    }
1561
1562    #[test]
1563    fn stream_options_preserve_boolean_wire_shape_and_reject_other_types() {
1564        let options: ChatCompletionStreamOptions = serde_json::from_value(serde_json::json!({
1565            "include_usage": true,
1566            "continuous_usage_stats": false,
1567        }))
1568        .unwrap();
1569        assert!(options.include_usage);
1570        assert!(!options.continuous_usage_stats);
1571        assert_eq!(
1572            serde_json::to_value(options).unwrap(),
1573            serde_json::json!({
1574                "include_usage": true,
1575                "continuous_usage_stats": false,
1576            })
1577        );
1578
1579        for payload in [
1580            serde_json::json!({"include_usage": "true"}),
1581            serde_json::json!({"continuous_usage_stats": 1}),
1582        ] {
1583            serde_json::from_value::<ChatCompletionStreamOptions>(payload).unwrap_err();
1584        }
1585    }
1586
1587    #[test]
1588    fn stop_accepts_token_id_array() {
1589        let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
1590
1591        assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
1592    }
1593
1594    #[test]
1595    fn stop_accepts_string_and_string_array() {
1596        let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
1597
1598        assert_eq!(stop, Stop::String(" The".to_string()));
1599
1600        let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
1601
1602        assert_eq!(
1603            stop,
1604            Stop::StringArray(vec!["A".to_string(), "B".to_string()])
1605        );
1606    }
1607
1608    #[test]
1609    fn stop_token_id_display_string_remains_string_stop() {
1610        let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
1611
1612        assert_eq!(stop, Stop::String("token_id:576".to_string()));
1613
1614        let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
1615
1616        assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
1617    }
1618
1619    #[test]
1620    fn stop_rejects_single_token_id() {
1621        let result = serde_json::from_value::<Stop>(serde_json::json!(576));
1622
1623        assert!(result.is_err());
1624    }
1625
1626    #[test]
1627    fn stop_converts_from_upstream_stop_configuration() {
1628        let upstream =
1629            async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
1630
1631        assert_eq!(
1632            Stop::from(upstream),
1633            Stop::StringArray(vec!["END".to_string()])
1634        );
1635    }
1636
1637    #[test]
1638    fn request_builder_accepts_upstream_reasoning_effort() {
1639        let request = CreateChatCompletionRequestArgs::default()
1640            .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
1641            .build()
1642            .unwrap();
1643
1644        assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
1645    }
1646
1647    #[test]
1648    fn tool_call_defaults_type_on_deserialize() {
1649        let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1650            "id": "call_123",
1651            "function": {
1652                "name": "get_weather",
1653                "arguments": "{\"location\":\"SF\"}"
1654            }
1655        }))
1656        .unwrap();
1657
1658        assert_eq!(tool_call.r#type, FunctionType::Function);
1659    }
1660
1661    #[test]
1662    fn tool_call_serializes_type_for_wire_compat() {
1663        let tool_call = ChatCompletionMessageToolCall {
1664            id: "call_123".into(),
1665            r#type: FunctionType::Function,
1666            function: FunctionCall {
1667                name: "get_weather".into(),
1668                arguments: "{\"location\":\"SF\"}".into(),
1669            },
1670        };
1671
1672        let json = serde_json::to_value(tool_call).unwrap();
1673        assert_eq!(json["type"], "function");
1674    }
1675
1676    // -- dict-format arguments tests --
1677
1678    #[test]
1679    fn function_call_accepts_string_arguments() {
1680        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1681            "name": "get_weather",
1682            "arguments": "{\"location\":\"SF\"}"
1683        }))
1684        .unwrap();
1685        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1686    }
1687
1688    #[test]
1689    fn function_call_accepts_dict_arguments() {
1690        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1691            "name": "get_weather",
1692            "arguments": {"location": "SF"}
1693        }))
1694        .unwrap();
1695        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1696    }
1697
1698    #[test]
1699    fn function_call_rejects_integer_arguments() {
1700        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1701            "name": "f",
1702            "arguments": 42
1703        }));
1704        assert!(result.is_err());
1705    }
1706
1707    #[test]
1708    fn function_call_rejects_boolean_arguments() {
1709        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1710            "name": "f",
1711            "arguments": true
1712        }));
1713        assert!(result.is_err());
1714    }
1715
1716    #[test]
1717    fn function_call_rejects_null_arguments() {
1718        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1719            "name": "f",
1720            "arguments": null
1721        }));
1722        assert!(result.is_err());
1723    }
1724
1725    #[test]
1726    fn function_call_rejects_array_arguments() {
1727        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1728            "name": "f",
1729            "arguments": [1, 2, 3]
1730        }));
1731        assert!(result.is_err());
1732    }
1733
1734    #[test]
1735    fn function_call_stream_null_arguments_produces_none() {
1736        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1737            "name": "f",
1738            "arguments": null
1739        }))
1740        .unwrap();
1741        assert_eq!(fcs.arguments, None);
1742    }
1743
1744    #[test]
1745    fn function_call_stream_rejects_integer_arguments() {
1746        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1747            "name": "f",
1748            "arguments": 42
1749        }));
1750        assert!(result.is_err());
1751    }
1752
1753    #[test]
1754    fn function_call_stream_rejects_boolean_arguments() {
1755        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1756            "name": "f",
1757            "arguments": true
1758        }));
1759        assert!(result.is_err());
1760    }
1761
1762    #[test]
1763    fn function_call_stream_accepts_dict_arguments() {
1764        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1765            "name": "get_weather",
1766            "arguments": {"location": "SF"}
1767        }))
1768        .unwrap();
1769        assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1770    }
1771
1772    #[test]
1773    fn function_call_stream_accepts_null_arguments() {
1774        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1775            "name": "get_weather"
1776        }))
1777        .unwrap();
1778        assert_eq!(fcs.arguments, None);
1779    }
1780
1781    #[test]
1782    fn tool_call_with_dict_arguments_roundtrip() {
1783        let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1784            "id": "call_abc",
1785            "type": "function",
1786            "function": {
1787                "name": "search",
1788                "arguments": {"query": "hello", "limit": 10}
1789            }
1790        }))
1791        .unwrap();
1792        // Compare as parsed JSON values since key order is non-deterministic
1793        let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1794        assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1795        // Re-serialisation produces a string, not an object
1796        let json = serde_json::to_value(&tc).unwrap();
1797        assert!(json["function"]["arguments"].is_string());
1798    }
1799
1800    #[test]
1801    fn stream_delta_function_call_accepts_dict_arguments() {
1802        let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1803            serde_json::from_value(serde_json::json!({
1804                "name": "get_weather",
1805                "arguments": {"location": "SF"}
1806            }))
1807            .unwrap();
1808        assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1809    }
1810
1811    fn parse_content_part(json: serde_json::Value) -> ChatCompletionRequestUserMessageContentPart {
1812        serde_json::from_value(json).expect("content part deserialization failed")
1813    }
1814
1815    #[test]
1816    fn image_url_url_and_top_level_uuid() {
1817        let part = parse_content_part(serde_json::json!({
1818            "type": "image_url",
1819            "image_url": {"url": "https://x.example/y.png"},
1820            "uuid": "image-123"
1821        }));
1822
1823        match part {
1824            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1825                assert_eq!(part.uuid.as_deref(), Some("image-123"));
1826                assert_eq!(
1827                    part.image_url.as_ref().map(|image| image.url.as_str()),
1828                    Some("https://x.example/y.png")
1829                );
1830            }
1831            _ => panic!("expected image_url part"),
1832        }
1833    }
1834
1835    #[test]
1836    fn image_url_null_and_top_level_uuid() {
1837        let part = parse_content_part(serde_json::json!({
1838            "type": "image_url",
1839            "image_url": null,
1840            "uuid": "sku-1234-a"
1841        }));
1842
1843        match part {
1844            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1845                assert!(part.image_url.is_none());
1846                assert_eq!(part.uuid.as_deref(), Some("sku-1234-a"));
1847            }
1848            _ => panic!("expected image_url part"),
1849        }
1850    }
1851
1852    #[test]
1853    fn empty_media_urls_deserialize_as_uuid_only() {
1854        for (part_type, media_field, uuid) in [
1855            ("image_url", "image_url", "image-cache-key"),
1856            ("video_url", "video_url", "video-cache-key"),
1857            ("audio_url", "audio_url", "audio-cache-key"),
1858        ] {
1859            let part = parse_content_part(serde_json::json!({
1860                "type": part_type,
1861                (media_field): {"url": ""},
1862                "uuid": uuid
1863            }));
1864            let json = serde_json::to_value(part).unwrap();
1865
1866            assert!(json[media_field].is_null());
1867            assert_eq!(json["uuid"], uuid);
1868        }
1869    }
1870
1871    #[test]
1872    fn image_url_null_without_uuid_deserializes_for_use_site_validation() {
1873        let part = parse_content_part(serde_json::json!({
1874            "type": "image_url",
1875            "image_url": null
1876        }));
1877
1878        match part {
1879            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1880                assert!(part.image_url.is_none());
1881                assert!(part.uuid.is_none());
1882            }
1883            _ => panic!("expected image_url part"),
1884        }
1885    }
1886
1887    #[test]
1888    fn image_url_serialize_uuid_only_uses_null_image_url() {
1889        let part = ChatCompletionRequestMessageContentPartImage {
1890            image_url: None,
1891            uuid: Some("image-123".to_string()),
1892        };
1893        let json = serde_json::to_value(part).unwrap();
1894
1895        assert!(json["image_url"].is_null());
1896        assert_eq!(json["uuid"], "image-123");
1897    }
1898
1899    #[test]
1900    fn cached_media_builders_allow_omitting_urls() {
1901        let image = ChatCompletionRequestMessageContentPartImageArgs::default()
1902            .uuid("image-123")
1903            .build()
1904            .unwrap();
1905        let video = ChatCompletionRequestMessageContentPartVideoArgs::default()
1906            .uuid("video-123")
1907            .build()
1908            .unwrap();
1909        let audio = ChatCompletionRequestMessageContentPartAudioUrlArgs::default()
1910            .uuid("audio-123")
1911            .build()
1912            .unwrap();
1913
1914        let image_json = serde_json::to_value(image).unwrap();
1915        let video_json = serde_json::to_value(video).unwrap();
1916        let audio_json = serde_json::to_value(audio).unwrap();
1917        assert!(image_json["image_url"].is_null());
1918        assert!(video_json["video_url"].is_null());
1919        assert!(audio_json["audio_url"].is_null());
1920    }
1921
1922    #[test]
1923    fn image_url_uuid_accepts_opaque_string() {
1924        let part = parse_content_part(serde_json::json!({
1925            "type": "image_url",
1926            "image_url": {"url": "https://x.example/y.png"},
1927            "uuid": "img-ac3921de680bb217"
1928        }));
1929
1930        match part {
1931            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1932                assert_eq!(part.uuid.as_deref(), Some("img-ac3921de680bb217"));
1933            }
1934            _ => panic!("expected image_url part"),
1935        }
1936    }
1937
1938    #[test]
1939    fn url_conversions_preserve_required_urls() {
1940        let image: ImageUrl = "https://x.example/image.png".into();
1941        let video: VideoUrl = "https://x.example/video.mp4".into();
1942        let audio: AudioUrl = "https://x.example/audio.wav".into();
1943
1944        assert_eq!(image.url.as_str(), "https://x.example/image.png");
1945        assert_eq!(video.url.as_str(), "https://x.example/video.mp4");
1946        assert_eq!(audio.url.as_str(), "https://x.example/audio.wav");
1947    }
1948
1949    #[test]
1950    fn invalid_media_urls_remain_rejected() {
1951        for (part_type, media_field) in [
1952            ("image_url", "image_url"),
1953            ("video_url", "video_url"),
1954            ("audio_url", "audio_url"),
1955        ] {
1956            let result = serde_json::from_value::<ChatCompletionRequestUserMessageContentPart>(
1957                serde_json::json!({
1958                    "type": part_type,
1959                    (media_field): {"url": "not a url"},
1960                    "uuid": "cache-key"
1961                }),
1962            );
1963
1964            assert!(result.is_err(), "{part_type} accepted an invalid URL");
1965        }
1966    }
1967
1968    #[test]
1969    fn legacy_nested_media_uuids_remain_accepted() {
1970        let legacy_uuid = "92b888ad-e64a-478f-b688-5091e16544e3";
1971
1972        for (part_type, media_field, url) in [
1973            ("image_url", "image_url", "https://x.example/image.png"),
1974            ("video_url", "video_url", "https://x.example/video.mp4"),
1975            ("audio_url", "audio_url", "https://x.example/audio.wav"),
1976        ] {
1977            let part = parse_content_part(serde_json::json!({
1978                "type": part_type,
1979                (media_field): {"url": url, "uuid": legacy_uuid}
1980            }));
1981            let json = serde_json::to_value(part).unwrap();
1982
1983            assert_eq!(json[media_field]["url"], url);
1984            assert_eq!(json[media_field]["uuid"], legacy_uuid);
1985            assert!(json.get("uuid").is_none());
1986        }
1987    }
1988
1989    #[test]
1990    fn video_url_null_and_top_level_uuid() {
1991        let part = parse_content_part(serde_json::json!({
1992            "type": "video_url",
1993            "video_url": null,
1994            "uuid": "video-cache-key"
1995        }));
1996
1997        match part {
1998            ChatCompletionRequestUserMessageContentPart::VideoUrl(part) => {
1999                assert!(part.video_url.is_none());
2000                assert_eq!(part.uuid.as_deref(), Some("video-cache-key"));
2001            }
2002            _ => panic!("expected video_url part"),
2003        }
2004    }
2005
2006    #[test]
2007    fn audio_url_null_and_top_level_uuid() {
2008        let part = parse_content_part(serde_json::json!({
2009            "type": "audio_url",
2010            "audio_url": null,
2011            "uuid": "audio-cache-key"
2012        }));
2013
2014        match part {
2015            ChatCompletionRequestUserMessageContentPart::AudioUrl(part) => {
2016                assert!(part.audio_url.is_none());
2017                assert_eq!(part.uuid.as_deref(), Some("audio-cache-key"));
2018            }
2019            _ => panic!("expected audio_url part"),
2020        }
2021    }
2022
2023    #[test]
2024    fn message_content_array_preserves_uuid_alignment() {
2025        let payload = serde_json::json!({
2026            "role": "user",
2027            "content": [
2028                {"type": "text", "text": "describe these"},
2029                {
2030                    "type": "image_url",
2031                    "image_url": {"url": "https://x.example/img1.png"},
2032                    "uuid": "image-1"
2033                },
2034                {"type": "image_url", "image_url": null, "uuid": "image-1"}
2035            ]
2036        });
2037        let message: ChatCompletionRequestUserMessage = serde_json::from_value(payload).unwrap();
2038        let ChatCompletionRequestUserMessageContent::Array(parts) = message.content else {
2039            panic!("expected content array");
2040        };
2041
2042        assert_eq!(parts.len(), 3);
2043        match &parts[1] {
2044            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
2045                assert!(
2046                    part.image_url
2047                        .as_ref()
2048                        .map(|image| image.url.as_str())
2049                        .is_some()
2050                );
2051                assert_eq!(part.uuid.as_deref(), Some("image-1"));
2052            }
2053            _ => panic!("parts[1] should be image_url"),
2054        }
2055        match &parts[2] {
2056            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
2057                assert!(part.image_url.is_none());
2058                assert_eq!(part.uuid.as_deref(), Some("image-1"));
2059            }
2060            _ => panic!("parts[2] should be image_url"),
2061        }
2062    }
2063
2064    #[test]
2065    fn tool_message_accepts_media_content() {
2066        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
2067            "role": "tool",
2068            "tool_call_id": "call_media",
2069            "content": [
2070                {"type": "text", "text": "Screenshot captured"},
2071                {
2072                    "type": "image_url",
2073                    "image_url": {
2074                        "url": "data:image/png;base64,aGVsbG8="
2075                    }
2076                },
2077                {
2078                    "type": "video_url",
2079                    "video_url": {
2080                        "url": "https://example.com/clip.mp4"
2081                    }
2082                },
2083                {
2084                    "type": "audio_url",
2085                    "audio_url": {
2086                        "url": "https://example.com/audio.wav"
2087                    }
2088                }
2089            ]
2090        }))
2091        .unwrap();
2092
2093        let ChatCompletionRequestMessage::Tool(tool) = message else {
2094            panic!("expected tool message");
2095        };
2096        let ChatCompletionRequestToolMessageContent::Array(parts) = tool.content else {
2097            panic!("expected array content");
2098        };
2099        assert!(matches!(
2100            parts[1],
2101            ChatCompletionRequestToolMessageContentPart::ImageUrl(_)
2102        ));
2103        assert!(matches!(
2104            parts[2],
2105            ChatCompletionRequestToolMessageContentPart::VideoUrl(_)
2106        ));
2107        assert!(matches!(
2108            parts[3],
2109            ChatCompletionRequestToolMessageContentPart::AudioUrl(_)
2110        ));
2111    }
2112
2113    #[test]
2114    fn chat_logprob_serializes_token_id_when_present() {
2115        let logprob = ChatCompletionTokenLogprob {
2116            token: " hello".into(),
2117            logprob: -0.12,
2118            token_id: Some(123),
2119            bytes: Some(vec![32, 104, 101, 108, 108, 111]),
2120            top_logprobs: vec![],
2121        };
2122
2123        let json = serde_json::to_value(logprob).unwrap();
2124
2125        assert_eq!(json["token_id"], 123);
2126    }
2127
2128    #[test]
2129    fn chat_logprob_deserializes_optional_fields() {
2130        let choice_logprobs: ChatChoiceLogprobs = serde_json::from_value(serde_json::json!({
2131            "content": [{
2132                "token": " hello",
2133                "logprob": -0.12,
2134                "top_logprobs": []
2135            }]
2136        }))
2137        .unwrap();
2138        let token_logprob: ChatCompletionTokenLogprob = serde_json::from_value(serde_json::json!({
2139            "token": " hello",
2140            "logprob": -0.12,
2141            "token_id": 123,
2142            "bytes": [32, 104, 101, 108, 108, 111],
2143            "top_logprobs": []
2144        }))
2145        .unwrap();
2146
2147        assert_eq!(choice_logprobs.content.as_ref().unwrap()[0].token_id, None);
2148        assert!(choice_logprobs.refusal.is_none());
2149        assert_eq!(token_logprob.token_id, Some(123));
2150        assert_eq!(token_logprob.bytes, Some(vec![32, 104, 101, 108, 108, 111]));
2151    }
2152
2153    #[test]
2154    fn chat_logprob_preserves_nullable_fields() {
2155        let choice_logprobs = ChatChoiceLogprobs {
2156            content: None,
2157            refusal: None,
2158        };
2159        let token_logprob = ChatCompletionTokenLogprob {
2160            token: " hello".into(),
2161            logprob: -0.12,
2162            token_id: None,
2163            bytes: None,
2164            top_logprobs: vec![],
2165        };
2166
2167        let choice_json = serde_json::to_value(choice_logprobs).unwrap();
2168        let token_json = serde_json::to_value(token_logprob).unwrap();
2169
2170        assert_eq!(choice_json["content"], serde_json::Value::Null);
2171        assert_eq!(choice_json["refusal"], serde_json::Value::Null);
2172        assert!(token_json.get("token_id").is_none());
2173        assert_eq!(token_json["bytes"], serde_json::Value::Null);
2174    }
2175
2176    #[test]
2177    #[allow(deprecated)]
2178    fn chat_response_omits_absent_optional_fields() {
2179        let response = CreateChatCompletionResponse {
2180            id: "chatcmpl_dummy".into(),
2181            choices: vec![ChatChoice {
2182                index: 0,
2183                message: ChatCompletionResponseMessage {
2184                    content: Some(ChatCompletionMessageContent::Text("hello".into())),
2185                    refusal: None,
2186                    tool_calls: None,
2187                    role: Role::Assistant,
2188                    function_call: None,
2189                    audio: None,
2190                    reasoning_content: None,
2191                },
2192                finish_reason: Some(FinishReason::Stop),
2193                logprobs: None,
2194            }],
2195            created: 0,
2196            model: "dummy-model".into(),
2197            service_tier: None,
2198            system_fingerprint: None,
2199            object: "chat.completion".into(),
2200            usage: None,
2201        };
2202
2203        let json = serde_json::to_value(response).unwrap();
2204
2205        for absent in ["usage", "service_tier", "system_fingerprint"] {
2206            assert!(json.get(absent).is_none(), "{absent} should be omitted");
2207        }
2208        let choice = &json["choices"][0];
2209        assert_eq!(choice["finish_reason"], "stop");
2210        assert_eq!(choice["logprobs"], serde_json::Value::Null);
2211        let message = &choice["message"];
2212        assert_eq!(message["refusal"], serde_json::Value::Null);
2213        for absent in ["tool_calls", "function_call", "audio", "reasoning_content"] {
2214            assert!(
2215                message.get(absent).is_none(),
2216                "message.{absent} should be omitted"
2217            );
2218        }
2219    }
2220
2221    #[test]
2222    fn stream_response_omits_absent_optional_fields() {
2223        let chunk = CreateChatCompletionStreamResponse {
2224            id: "chatcmpl_dummy".into(),
2225            choices: vec![ChatChoiceStream {
2226                index: 0,
2227                delta: ChatCompletionStreamResponseDelta {
2228                    content: Some(ChatCompletionMessageContent::Text("hello".into())),
2229                    function_call: None,
2230                    tool_calls: None,
2231                    role: None,
2232                    refusal: None,
2233                    reasoning_content: None,
2234                },
2235                finish_reason: None,
2236                logprobs: None,
2237            }],
2238            created: 0,
2239            model: "dummy-model".into(),
2240            service_tier: None,
2241            system_fingerprint: None,
2242            object: "chat.completion.chunk".into(),
2243            usage: None,
2244        };
2245
2246        let json = serde_json::to_value(chunk).unwrap();
2247
2248        for absent in ["usage", "service_tier", "system_fingerprint"] {
2249            assert!(json.get(absent).is_none(), "{absent} should be omitted");
2250        }
2251    }
2252
2253    #[test]
2254    fn stream_tool_call_continuation_chunk_omits_absent_fields() {
2255        let chunk = ChatCompletionMessageToolCallChunk {
2256            index: 0,
2257            id: None,
2258            r#type: None,
2259            function: Some(FunctionCallStream {
2260                name: None,
2261                arguments: Some("{\"a\":".into()),
2262            }),
2263        };
2264
2265        let json = serde_json::to_value(chunk).unwrap();
2266
2267        assert!(json.get("id").is_none());
2268        assert!(json.get("type").is_none());
2269        assert!(json["function"].get("name").is_none());
2270        assert_eq!(json["function"]["arguments"], "{\"a\":");
2271    }
2272
2273    #[test]
2274    fn stream_delta_function_call_omits_absent_fields() {
2275        let function_call = ChatCompletionStreamResponseDeltaFunctionCall {
2276            name: None,
2277            arguments: Some("{}".into()),
2278        };
2279
2280        let json = serde_json::to_value(function_call).unwrap();
2281
2282        assert!(json.get("name").is_none());
2283        assert_eq!(json["arguments"], "{}");
2284    }
2285
2286    #[test]
2287    fn usage_details_omit_absent_fields() {
2288        let response = CreateChatCompletionResponse {
2289            id: "chatcmpl_dummy".into(),
2290            choices: vec![],
2291            created: 0,
2292            model: "dummy-model".into(),
2293            service_tier: None,
2294            system_fingerprint: None,
2295            object: "chat.completion".into(),
2296            usage: Some(CompletionUsage {
2297                prompt_tokens: 10,
2298                completion_tokens: 25,
2299                total_tokens: 35,
2300                prompt_tokens_details: Some(PromptTokensDetails {
2301                    audio_tokens: None,
2302                    cached_tokens: Some(0),
2303                    ..Default::default()
2304                }),
2305                completion_tokens_details: Some(CompletionTokensDetails {
2306                    reasoning_tokens: Some(5),
2307                    ..Default::default()
2308                }),
2309            }),
2310        };
2311
2312        let json = serde_json::to_value(&response).unwrap();
2313        let usage = &json["usage"];
2314
2315        assert_eq!(usage["total_tokens"], 35);
2316        assert_eq!(usage["prompt_tokens_details"]["cached_tokens"], 0);
2317        assert!(
2318            usage["prompt_tokens_details"].get("audio_tokens").is_none(),
2319            "audio_tokens should be omitted, not null"
2320        );
2321        assert_eq!(usage["completion_tokens_details"]["reasoning_tokens"], 5);
2322        for absent in [
2323            "accepted_prediction_tokens",
2324            "audio_tokens",
2325            "rejected_prediction_tokens",
2326        ] {
2327            assert!(
2328                usage["completion_tokens_details"].get(absent).is_none(),
2329                "{absent} should be omitted"
2330            );
2331        }
2332
2333        let roundtrip: CreateChatCompletionResponse = serde_json::from_value(json).unwrap();
2334        assert_eq!(roundtrip, response);
2335    }
2336
2337    // -- Kimi-style system tools / assistant partial tests --
2338
2339    #[test]
2340    fn effective_tool_set_unions_top_level_and_dynamic_system_tools() {
2341        let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2342            "model": "dummy-kimi-model",
2343            "tools": [{
2344                "type": "function",
2345                "function": {"name": "add", "parameters": {"type": "object"}}
2346            }],
2347            "messages": [
2348                {"role": "user", "content": "start"},
2349                {
2350                    "role": "system",
2351                    "tools": [
2352                        {
2353                            "type": "function",
2354                            "function": {"name": "lookup", "parameters": {"type": "object"}}
2355                        },
2356                        {"name": "search", "parameters": {"type": "object"}},
2357                        {"description": "no name, skipped"}
2358                    ]
2359                },
2360                {"role": "user", "content": "continue"}
2361            ]
2362        }))
2363        .unwrap();
2364
2365        assert!(request.has_effective_tools());
2366        assert_eq!(request.dynamic_system_tools().count(), 3);
2367        assert_eq!(
2368            request.effective_tool_names().collect::<Vec<_>>(),
2369            ["add", "lookup", "search"],
2370            "top-level first, then dynamic in message order; wrapped and bare shapes both resolve"
2371        );
2372        for name in ["add", "lookup", "search"] {
2373            assert!(
2374                request.effective_tool_contains(name),
2375                "{name} should be found"
2376            );
2377        }
2378        assert!(!request.effective_tool_contains("missing"));
2379        assert!(
2380            !request.effective_tool_contains("no name, skipped"),
2381            "a description is not a name"
2382        );
2383    }
2384
2385    #[test]
2386    fn effective_tool_set_is_empty_without_any_declaration() {
2387        for payload in [
2388            serde_json::json!({
2389                "model": "m",
2390                "messages": [{"role": "user", "content": "hi"}]
2391            }),
2392            serde_json::json!({
2393                "model": "m",
2394                "tools": [],
2395                "messages": [{"role": "system", "content": "plain system text"}]
2396            }),
2397        ] {
2398            let request: CreateChatCompletionRequest = serde_json::from_value(payload).unwrap();
2399            assert!(!request.has_effective_tools());
2400            assert_eq!(request.effective_tool_names().count(), 0);
2401            assert!(!request.effective_tool_contains("anything"));
2402        }
2403    }
2404
2405    #[test]
2406    fn dynamic_system_tools_alone_count_as_effective_tools() {
2407        let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2408            "model": "dummy-kimi-model",
2409            "messages": [
2410                {"role": "system", "tools": [{"name": "lookup"}]},
2411                {"role": "user", "content": "go"}
2412            ]
2413        }))
2414        .unwrap();
2415
2416        assert!(
2417            request.tools.is_none(),
2418            "nothing was folded into top-level tools"
2419        );
2420        assert!(request.has_effective_tools());
2421        assert!(request.effective_tool_contains("lookup"));
2422    }
2423
2424    #[test]
2425    fn dynamic_tool_name_handles_wrapped_bare_and_invalid_shapes() {
2426        assert_eq!(
2427            dynamic_tool_name(&serde_json::json!({"type": "function", "function": {"name": "a"}})),
2428            Some("a")
2429        );
2430        assert_eq!(
2431            dynamic_tool_name(&serde_json::json!({"name": "b"})),
2432            Some("b")
2433        );
2434        assert_eq!(dynamic_tool_name(&serde_json::json!({"name": 7})), None);
2435    }
2436
2437    #[test]
2438    fn system_message_without_content_is_rejected_unless_it_declares_tools() {
2439        // Same leading text as upstream's derived error, so clients and
2440        // tests matching on "missing field `content`" keep working.
2441        for (label, message) in [
2442            ("nothing", serde_json::json!({"role": "system"})),
2443            (
2444                "empty tools",
2445                serde_json::json!({"role": "system", "tools": []}),
2446            ),
2447        ] {
2448            let error =
2449                serde_json::from_value::<ChatCompletionRequestMessage>(message).expect_err(label);
2450            assert!(
2451                error.to_string().starts_with("missing field `content`"),
2452                "{label}: unexpected error {error}"
2453            );
2454        }
2455    }
2456
2457    #[test]
2458    fn system_message_guard_leaves_valid_shapes_alone() {
2459        for (label, message) in [
2460            (
2461                "content only",
2462                serde_json::json!({"role": "system", "content": "hi"}),
2463            ),
2464            (
2465                "content parts",
2466                serde_json::json!({"role": "system", "content": [{"type": "text", "text": "hi"}]}),
2467            ),
2468            (
2469                "tools only",
2470                serde_json::json!({"role": "system", "tools": [{"name": "lookup"}]}),
2471            ),
2472            (
2473                "content and tools (renderer decides)",
2474                serde_json::json!({"role": "system", "content": "hi", "tools": [{"name": "lookup"}]}),
2475            ),
2476        ] {
2477            let parsed: ChatCompletionRequestMessage =
2478                serde_json::from_value(message).unwrap_or_else(|e| panic!("{label}: {e}"));
2479            assert!(
2480                matches!(parsed, ChatCompletionRequestMessage::System(_)),
2481                "{label}"
2482            );
2483        }
2484    }
2485
2486    #[test]
2487    fn message_rejects_tools_and_partial_on_wrong_roles() {
2488        let tools = serde_json::json!([{"name": "lookup"}]);
2489        for (label, message, needle) in [
2490            (
2491                "tools on user",
2492                serde_json::json!({"role": "user", "content": "hi", "tools": tools}),
2493                "`tools` is only accepted on system messages, not on role user",
2494            ),
2495            (
2496                "tools on assistant",
2497                serde_json::json!({"role": "assistant", "content": "hi", "tools": tools}),
2498                "`tools` is only accepted on system messages, not on role assistant",
2499            ),
2500            (
2501                // Upstream type without a `tools` field: accepting would drop them.
2502                "tools on developer",
2503                serde_json::json!({"role": "developer", "content": "hi", "tools": tools}),
2504                "`tools` is only accepted on system messages, not on role developer",
2505            ),
2506            (
2507                "partial on user",
2508                serde_json::json!({"role": "user", "content": "hi", "partial": true}),
2509                "`partial` is only accepted on assistant messages, not on role user",
2510            ),
2511            (
2512                "partial on system",
2513                serde_json::json!({"role": "system", "content": "hi", "partial": false}),
2514                "`partial` is only accepted on assistant messages, not on role system",
2515            ),
2516        ] {
2517            let error = serde_json::from_value::<ChatCompletionRequestMessage>(message)
2518                .expect_err(label)
2519                .to_string();
2520            assert!(error.contains(needle), "{label}: {error}");
2521        }
2522
2523        for message in [
2524            serde_json::json!({"role": "user", "content": "hi", "tools": null}),
2525            serde_json::json!({"role": "user", "content": "hi", "partial": null}),
2526        ] {
2527            serde_json::from_value::<ChatCompletionRequestMessage>(message).unwrap();
2528        }
2529
2530        for message in [
2531            serde_json::json!({"role": "system", "tools": tools}),
2532            serde_json::json!({"role": "assistant", "content": "seed", "partial": true}),
2533            serde_json::json!({"role": "user", "content": "hi", "x_vendor": 1}),
2534        ] {
2535            serde_json::from_value::<ChatCompletionRequestMessage>(message).unwrap();
2536        }
2537    }
2538
2539    #[test]
2540    fn message_rejects_duplicate_top_level_keys() {
2541        for (label, raw) in [
2542            (
2543                "role twice",
2544                r#"{"role":"user","content":"hi","role":"system"}"#,
2545            ),
2546            (
2547                "content twice",
2548                r#"{"role":"user","content":"a","content":"b"}"#,
2549            ),
2550        ] {
2551            let error = serde_json::from_str::<ChatCompletionRequestMessage>(raw)
2552                .expect_err(label)
2553                .to_string();
2554            assert!(error.contains("duplicate field"), "{label}: {error}");
2555        }
2556    }
2557
2558    #[test]
2559    fn message_rejects_duplicate_fields_in_nested_typed_objects() {
2560        let tool_call = r#"{
2561            "role":"assistant",
2562            "content":null,
2563            "tool_calls":[{
2564                "id":"first",
2565                "id":"second",
2566                "type":"function",
2567                "function":{"name":"lookup","arguments":"{}"}
2568            }]
2569        }"#;
2570        let error = serde_json::from_str::<ChatCompletionRequestMessage>(tool_call)
2571            .unwrap_err()
2572            .to_string();
2573        assert!(error.contains("duplicate field `id`"), "{error}");
2574
2575        let content_part = r#"{
2576            "role":"user",
2577            "content":[{"type":"text","text":"first","text":"second"}]
2578        }"#;
2579        assert!(serde_json::from_str::<ChatCompletionRequestMessage>(content_part).is_err());
2580    }
2581
2582    #[test]
2583    fn default_system_message_round_trips() {
2584        let message = ChatCompletionRequestSystemMessage::default();
2585        let json = serde_json::to_value(&message).unwrap();
2586        assert_eq!(json, serde_json::json!({"content": ""}));
2587        let back: ChatCompletionRequestSystemMessage = serde_json::from_value(json).unwrap();
2588        assert_eq!(back, message);
2589
2590        let built = ChatCompletionRequestSystemMessageArgs::default()
2591            .name("ops")
2592            .build()
2593            .unwrap();
2594        let json = serde_json::to_value(&built).unwrap();
2595        assert_eq!(json, serde_json::json!({"content": "", "name": "ops"}));
2596        serde_json::from_value::<ChatCompletionRequestSystemMessage>(json).unwrap();
2597    }
2598
2599    #[test]
2600    fn system_message_guard_keeps_field_level_errors() {
2601        let error = serde_json::from_value::<ChatCompletionRequestMessage>(serde_json::json!({
2602            "role": "system",
2603            "tools": "lookup"
2604        }))
2605        .unwrap_err();
2606        assert!(
2607            !error.to_string().starts_with("missing field `content`"),
2608            "field error expected, got {error}"
2609        );
2610    }
2611
2612    #[test]
2613    fn system_message_canonicalizes_missing_content_with_tools() {
2614        let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2615            "model": "dummy-kimi-model",
2616            "messages": [
2617                {
2618                    "role": "system",
2619                    "tools": [
2620                        {
2621                            "name": "lookup",
2622                            "description": "dummy lookup tool",
2623                            "parameters": {
2624                                "type": "object",
2625                                "properties": {
2626                                    "query": { "type": "string" }
2627                                }
2628                            }
2629                        }
2630                    ]
2631                },
2632                {
2633                    "role": "assistant",
2634                    "content": "synthetic prefill",
2635                    "partial": true
2636                },
2637                {
2638                    "role": "user",
2639                    "content": "continue"
2640                }
2641            ]
2642        }))
2643        .unwrap();
2644
2645        match &request.messages[0] {
2646            ChatCompletionRequestMessage::System(system) => {
2647                assert_eq!(
2648                    system.content,
2649                    ChatCompletionRequestSystemMessageContent::Text(String::new())
2650                );
2651                let tools = system.tools.as_ref().expect("tools should be present");
2652                assert_eq!(tools.len(), 1);
2653                assert_eq!(tools[0]["name"], "lookup");
2654            }
2655            other => panic!("expected system message, got {other:?}"),
2656        }
2657
2658        match &request.messages[1] {
2659            ChatCompletionRequestMessage::Assistant(assistant) => {
2660                assert_eq!(assistant.partial, Some(true));
2661            }
2662            other => panic!("expected assistant message, got {other:?}"),
2663        }
2664
2665        // Explicit null has the same wire meaning as omission. Both serialize
2666        // to the canonical required-content shape.
2667        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
2668            "role": "system",
2669            "content": null,
2670            "tools": [{"name": "lookup"}]
2671        }))
2672        .unwrap();
2673        let ChatCompletionRequestMessage::System(system) = &message else {
2674            panic!("expected system message");
2675        };
2676        assert_eq!(
2677            system.content,
2678            ChatCompletionRequestSystemMessageContent::Text(String::new())
2679        );
2680        assert_eq!(
2681            serde_json::to_value(message).unwrap(),
2682            serde_json::json!({
2683                "role": "system",
2684                "content": "",
2685                "tools": [{"name": "lookup"}]
2686            })
2687        );
2688    }
2689
2690    #[test]
2691    fn kimi_style_request_preserves_tools_and_canonicalizes_content() {
2692        let payload = serde_json::json!({
2693            "model": "dummy-kimi-model",
2694            "messages": [
2695                {
2696                    "role": "system",
2697                    "tools": [
2698                        {
2699                            "name": "lookup",
2700                            "description": "dummy lookup tool",
2701                            "parameters": {
2702                                "type": "object",
2703                                "properties": {
2704                                    "query": { "type": "string" }
2705                                }
2706                            },
2707                            "vendor_hint": { "priority": 3 }
2708                        }
2709                    ]
2710                },
2711                {
2712                    "role": "assistant",
2713                    "content": "synthetic prefill",
2714                    "partial": true
2715                },
2716                {
2717                    "role": "user",
2718                    "content": "continue"
2719                }
2720            ]
2721        });
2722
2723        let request: CreateChatCompletionRequest = serde_json::from_value(payload.clone()).unwrap();
2724        let serialized = serde_json::to_value(request).unwrap();
2725        let mut canonical = payload;
2726        canonical["messages"][0]["content"] = serde_json::json!("");
2727
2728        assert_eq!(serialized, canonical);
2729    }
2730
2731    #[test]
2732    fn system_message_tools_preserve_official_wrapped_shape() {
2733        let payload = serde_json::json!({
2734            "model": "dummy-kimi-model",
2735            "messages": [
2736                {
2737                    "role": "system",
2738                    "tools": [
2739                        {
2740                            "type": "function",
2741                            "function": {
2742                                "name": "lookup",
2743                                "description": "dummy lookup tool",
2744                                "parameters": {
2745                                    "type": "object",
2746                                    "properties": {
2747                                        "query": { "type": "string" }
2748                                    },
2749                                    "required": ["query"]
2750                                },
2751                                "strict": true
2752                            }
2753                        }
2754                    ]
2755                },
2756                { "role": "user", "content": "continue" }
2757            ]
2758        });
2759
2760        let request: CreateChatCompletionRequest = serde_json::from_value(payload.clone()).unwrap();
2761        match &request.messages[0] {
2762            ChatCompletionRequestMessage::System(system) => {
2763                let tools = system.tools.as_ref().expect("tools should be present");
2764                assert_eq!(tools[0]["type"], "function");
2765                assert_eq!(tools[0]["function"]["name"], "lookup");
2766            }
2767            other => panic!("expected system message, got {other:?}"),
2768        }
2769
2770        let mut canonical = payload;
2771        canonical["messages"][0]["content"] = serde_json::json!("");
2772        assert_eq!(serde_json::to_value(request).unwrap(), canonical);
2773    }
2774
2775    #[test]
2776    fn assistant_message_omits_partial_when_absent() {
2777        let assistant = ChatCompletionRequestAssistantMessageArgs::default()
2778            .content("hello")
2779            .build()
2780            .unwrap();
2781
2782        assert_eq!(assistant.partial, None);
2783        let json = serde_json::to_value(&assistant).unwrap();
2784        assert!(
2785            json.get("partial").is_none(),
2786            "partial should be omitted when absent"
2787        );
2788    }
2789
2790    #[test]
2791    fn assistant_message_serializes_partial_when_present() {
2792        let assistant = ChatCompletionRequestAssistantMessageArgs::default()
2793            .content("synthetic prefill")
2794            .partial(true)
2795            .build()
2796            .unwrap();
2797
2798        let json = serde_json::to_value(&assistant).unwrap();
2799        assert_eq!(json["partial"], true);
2800
2801        let roundtrip: ChatCompletionRequestAssistantMessage =
2802            serde_json::from_value(json).unwrap();
2803        assert_eq!(roundtrip, assistant);
2804    }
2805
2806    #[test]
2807    fn system_message_from_upstream_preserves_content_and_leaves_tools_none() {
2808        let upstream = async_openai::types::chat::ChatCompletionRequestSystemMessage {
2809            content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
2810                "hi".into(),
2811            ),
2812            name: None,
2813        };
2814
2815        let owned: ChatCompletionRequestSystemMessage = upstream.into();
2816        assert!(owned.tools.is_none());
2817        match owned.content {
2818            ChatCompletionRequestSystemMessageContent::Text(text) => assert_eq!(text, "hi"),
2819            other => panic!("expected text content, got {other:?}"),
2820        }
2821    }
2822
2823    #[test]
2824    fn system_message_restores_upstream_convenience_conversions() {
2825        let from_content = ChatCompletionRequestSystemMessage::from(
2826            ChatCompletionRequestSystemMessageContent::Text("from content".into()),
2827        );
2828        let from_str = ChatCompletionRequestSystemMessage::from("from str");
2829        let from_string = ChatCompletionRequestSystemMessage::from(String::from("from string"));
2830
2831        for (message, expected) in [
2832            (from_content, "from content"),
2833            (from_str, "from str"),
2834            (from_string, "from string"),
2835        ] {
2836            assert_eq!(
2837                message.content,
2838                ChatCompletionRequestSystemMessageContent::Text(expected.into())
2839            );
2840            assert!(message.name.is_none());
2841            assert!(message.tools.is_none());
2842        }
2843    }
2844}