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