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
988fn deserialize_null_as_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
989where
990    D: serde::Deserializer<'de>,
991{
992    Option::<bool>::deserialize(deserializer).map(Option::unwrap_or_default)
993}
994
995/// Stream options with per-chunk usage reporting.
996///
997/// Extends upstream `ChatCompletionStreamOptions` with:
998/// - `continuous_usage_stats`: emit usage in every chunk, not just the final one
999#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
1000pub struct ChatCompletionStreamOptions {
1001    #[serde(default, deserialize_with = "deserialize_null_as_false")]
1002    pub include_usage: bool,
1003    /// When true, usage statistics are included in every streaming chunk.
1004    /// Backends like vLLM/SGLang support this for real-time token counting.
1005    #[serde(default, deserialize_with = "deserialize_null_as_false")]
1006    pub continuous_usage_stats: bool,
1007}
1008
1009/// Chat completion request with multimodal processor support.
1010///
1011/// Extends upstream `CreateChatCompletionRequest` with:
1012/// - `mm_processor_kwargs`: multimodal processor configuration (vLLM-specific)
1013/// - Uses our extended `ChatCompletionRequestMessage` (with reasoning, video/audio)
1014/// - Uses our extended `ChatCompletionStreamOptions` (with continuous_usage_stats)
1015#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
1016#[builder(name = "CreateChatCompletionRequestArgs")]
1017#[builder(pattern = "mutable")]
1018#[builder(setter(into, strip_option), default)]
1019#[builder(derive(Debug))]
1020#[builder(build_fn(error = "OpenAIError"))]
1021pub struct CreateChatCompletionRequest {
1022    pub messages: Vec<ChatCompletionRequestMessage>,
1023    pub model: String,
1024    /// Multimodal processor configuration (vLLM-specific)
1025    #[serde(skip_serializing_if = "Option::is_none")]
1026    pub mm_processor_kwargs: Option<serde_json::Value>,
1027    #[serde(skip_serializing_if = "Option::is_none")]
1028    pub store: Option<bool>,
1029    #[serde(skip_serializing_if = "Option::is_none")]
1030    pub reasoning_effort: Option<ReasoningEffort>,
1031    #[serde(skip_serializing_if = "Option::is_none")]
1032    pub metadata: Option<serde_json::Value>,
1033    #[serde(skip_serializing_if = "Option::is_none")]
1034    pub frequency_penalty: Option<f32>,
1035    #[serde(skip_serializing_if = "Option::is_none")]
1036    pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
1037    #[serde(skip_serializing_if = "Option::is_none")]
1038    pub logprobs: Option<bool>,
1039    #[serde(skip_serializing_if = "Option::is_none")]
1040    pub top_logprobs: Option<u8>,
1041    #[deprecated]
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub max_tokens: Option<u32>,
1044    #[serde(skip_serializing_if = "Option::is_none")]
1045    pub max_completion_tokens: Option<u32>,
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    pub n: Option<u8>,
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    pub prediction: Option<PredictionContent>,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    pub audio: Option<ChatCompletionAudio>,
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub presence_penalty: Option<f32>,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub response_format: Option<ResponseFormat>,
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    pub seed: Option<i64>,
1060    #[serde(skip_serializing_if = "Option::is_none")]
1061    pub service_tier: Option<ServiceTier>,
1062    #[serde(skip_serializing_if = "Option::is_none")]
1063    pub stop: Option<Stop>,
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub stream: Option<bool>,
1066    #[serde(skip_serializing_if = "Option::is_none")]
1067    pub stream_options: Option<ChatCompletionStreamOptions>,
1068    #[serde(skip_serializing_if = "Option::is_none")]
1069    pub temperature: Option<f32>,
1070    #[serde(skip_serializing_if = "Option::is_none")]
1071    pub top_p: Option<f32>,
1072    #[serde(skip_serializing_if = "Option::is_none")]
1073    pub tools: Option<Vec<ChatCompletionTool>>,
1074    #[serde(skip_serializing_if = "Option::is_none")]
1075    pub tool_choice: Option<ChatCompletionToolChoiceOption>,
1076    #[serde(skip_serializing_if = "Option::is_none")]
1077    pub parallel_tool_calls: Option<bool>,
1078    #[serde(skip_serializing_if = "Option::is_none")]
1079    pub user: Option<String>,
1080    /// OpenAI cache-affinity hint: requests sharing a prompt prefix send the
1081    /// same key (Kimi Code CLI sends its session id on every request).
1082    ///
1083    /// NOTICE: accepted and preserved only. Nothing in this crate or in Dynamo
1084    /// acts on it yet — Dynamo's KV-aware router keys on prompt-prefix block
1085    /// hashes, not on this value.
1086    // TODO(routing): decide whether `prompt_cache_key` should feed router
1087    // affinity (e.g. as a tie-breaker or session pin) and plumb it through.
1088    #[serde(skip_serializing_if = "Option::is_none")]
1089    pub prompt_cache_key: Option<String>,
1090    #[deprecated]
1091    #[serde(skip_serializing_if = "Option::is_none")]
1092    pub function_call: Option<ChatCompletionFunctionCall>,
1093    #[deprecated]
1094    #[serde(skip_serializing_if = "Option::is_none")]
1095    pub functions: Option<Vec<ChatCompletionFunctions>>,
1096    #[serde(skip_serializing_if = "Option::is_none")]
1097    pub web_search_options: Option<WebSearchOptions>,
1098}
1099
1100impl CreateChatCompletionRequest {
1101    /// Kimi-style dynamic tools declared on `system` messages, in message order.
1102    ///
1103    /// Kimi defines these as coexisting with the top-level `tools` list: a
1104    /// dynamic declaration keeps its position in the message history so the
1105    /// prompt prefix (and any KV cache built on it) stays intact. Do not fold
1106    /// them into `tools`; reason about the union with
1107    /// [`Self::has_effective_tools`] and [`Self::effective_tool_contains`].
1108    ///
1109    /// Only `system` messages can carry `tools` in the typed schema; the
1110    /// `developer` message is the upstream type and has no such field.
1111    pub fn dynamic_system_tools(&self) -> impl Iterator<Item = &serde_json::Value> {
1112        self.messages
1113            .iter()
1114            .filter_map(|message| match message {
1115                ChatCompletionRequestMessage::System(system) => system.tools.as_deref(),
1116                _ => None,
1117            })
1118            .flatten()
1119    }
1120
1121    /// Whether the request declares any tool, either top-level or through a
1122    /// dynamic system-message declaration.
1123    ///
1124    /// Gates that decide whether model output may be interpreted as tool
1125    /// calls must use this rather than `tools` alone, or a call to a
1126    /// dynamically declared tool is stripped from the response.
1127    pub fn has_effective_tools(&self) -> bool {
1128        self.tools.as_ref().is_some_and(|tools| !tools.is_empty())
1129            || self.dynamic_system_tools().next().is_some()
1130    }
1131
1132    /// Names of every tool the model can see: top-level `tools` first, then
1133    /// dynamic system-message tools in message order.
1134    pub fn effective_tool_names(&self) -> impl Iterator<Item = &str> {
1135        self.tools
1136            .iter()
1137            .flatten()
1138            .map(|tool| tool.function.name.as_str())
1139            .chain(self.dynamic_system_tools().filter_map(dynamic_tool_name))
1140    }
1141
1142    /// Whether `name` is declared anywhere in the effective tool set.
1143    ///
1144    /// Use this to validate a named `tool_choice` so a forced call to a
1145    /// dynamically declared tool is not rejected as "not present in tools".
1146    pub fn effective_tool_contains(&self, name: &str) -> bool {
1147        self.effective_tool_names().any(|tool| tool == name)
1148    }
1149}
1150
1151/// Name of a dynamic system-message tool entry.
1152///
1153/// Accepts both the OpenAI wrapped form
1154/// `{"type": "function", "function": {"name": ...}}` and the bare
1155/// function-schema form `{"name": ...}` that some Kimi clients send. Returns
1156/// `None` for entries with no string name.
1157pub fn dynamic_tool_name(tool: &serde_json::Value) -> Option<&str> {
1158    tool.get("function")
1159        .and_then(serde_json::Value::as_object)
1160        .and_then(|function| function.get("name"))
1161        .or_else(|| tool.get("name"))
1162        .and_then(serde_json::Value::as_str)
1163}
1164
1165/// Chat choice with extended response message.
1166///
1167/// Uses our `ChatCompletionResponseMessage` (multimodal content + reasoning).
1168#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1169pub struct ChatChoice {
1170    pub index: u32,
1171    pub message: ChatCompletionResponseMessage,
1172    pub finish_reason: Option<FinishReason>,
1173    pub logprobs: Option<ChatChoiceLogprobs>,
1174}
1175
1176/// Serializes `usage` through a shadow struct that omits absent optional
1177/// fields.
1178///
1179/// Upstream async-openai derives serialize `None` usage-details fields as
1180/// explicit `null` (e.g. `"audio_tokens": null`), but the spec marks every
1181/// usage-details field optional and non-nullable, so absent fields must be
1182/// omitted (OpenAI emits `"audio_tokens": 0`, never `null`). The shadow
1183/// keeps upstream `CompletionUsage` in the public API — replacing it with a
1184/// same-named local type would break callers that pass upstream values.
1185fn serialize_usage_omitting_absent<S>(
1186    usage: &Option<CompletionUsage>,
1187    serializer: S,
1188) -> Result<S::Ok, S::Error>
1189where
1190    S: serde::Serializer,
1191{
1192    #[derive(Serialize)]
1193    struct PromptDetailsShadow {
1194        #[serde(skip_serializing_if = "Option::is_none")]
1195        audio_tokens: Option<u32>,
1196        #[serde(skip_serializing_if = "Option::is_none")]
1197        cached_tokens: Option<u32>,
1198    }
1199
1200    #[derive(Serialize)]
1201    struct CompletionDetailsShadow {
1202        #[serde(skip_serializing_if = "Option::is_none")]
1203        accepted_prediction_tokens: Option<u32>,
1204        #[serde(skip_serializing_if = "Option::is_none")]
1205        audio_tokens: Option<u32>,
1206        #[serde(skip_serializing_if = "Option::is_none")]
1207        reasoning_tokens: Option<u32>,
1208        #[serde(skip_serializing_if = "Option::is_none")]
1209        rejected_prediction_tokens: Option<u32>,
1210    }
1211
1212    #[derive(Serialize)]
1213    struct UsageShadow {
1214        prompt_tokens: u32,
1215        completion_tokens: u32,
1216        total_tokens: u32,
1217        #[serde(skip_serializing_if = "Option::is_none")]
1218        prompt_tokens_details: Option<PromptDetailsShadow>,
1219        #[serde(skip_serializing_if = "Option::is_none")]
1220        completion_tokens_details: Option<CompletionDetailsShadow>,
1221    }
1222
1223    match usage {
1224        None => serializer.serialize_none(),
1225        Some(u) => UsageShadow {
1226            prompt_tokens: u.prompt_tokens,
1227            completion_tokens: u.completion_tokens,
1228            total_tokens: u.total_tokens,
1229            prompt_tokens_details: u
1230                .prompt_tokens_details
1231                .as_ref()
1232                .map(|d| PromptDetailsShadow {
1233                    audio_tokens: d.audio_tokens,
1234                    cached_tokens: d.cached_tokens,
1235                }),
1236            completion_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
1237                CompletionDetailsShadow {
1238                    accepted_prediction_tokens: d.accepted_prediction_tokens,
1239                    audio_tokens: d.audio_tokens,
1240                    reasoning_tokens: d.reasoning_tokens,
1241                    rejected_prediction_tokens: d.rejected_prediction_tokens,
1242                }
1243            }),
1244        }
1245        .serialize(serializer),
1246    }
1247}
1248
1249/// Non-streaming chat completion response.
1250///
1251/// `service_tier`, `system_fingerprint`, and `usage` are optional in the
1252/// spec and omitted (not serialized as `null`) when absent, matching
1253/// OpenAI output. `choices[].finish_reason` and `choices[].logprobs` stay
1254/// always-present: the spec marks them required (nullable for `logprobs`).
1255#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1256pub struct CreateChatCompletionResponse {
1257    pub id: String,
1258    pub choices: Vec<ChatChoice>,
1259    pub created: u32,
1260    pub model: String,
1261    #[serde(skip_serializing_if = "Option::is_none")]
1262    pub service_tier: Option<ServiceTierResponse>,
1263    #[serde(skip_serializing_if = "Option::is_none")]
1264    pub system_fingerprint: Option<String>,
1265    pub object: String,
1266    #[serde(
1267        skip_serializing_if = "Option::is_none",
1268        serialize_with = "serialize_usage_omitting_absent"
1269    )]
1270    pub usage: Option<CompletionUsage>,
1271}
1272
1273pub type ChatCompletionResponseStream =
1274    Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
1275
1276/// Streaming delta with reasoning content.
1277///
1278/// Extends upstream `ChatCompletionStreamResponseDelta` with:
1279/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
1280/// - `reasoning_content`: streaming reasoning tokens (DeepSeek-R1, QwQ)
1281#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1282pub struct ChatCompletionStreamResponseDelta {
1283    #[serde(skip_serializing_if = "Option::is_none")]
1284    pub content: Option<ChatCompletionMessageContent>,
1285    #[serde(skip_serializing_if = "Option::is_none")]
1286    pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
1287    #[serde(skip_serializing_if = "Option::is_none")]
1288    pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
1289    #[serde(skip_serializing_if = "Option::is_none")]
1290    pub role: Option<Role>,
1291    #[serde(skip_serializing_if = "Option::is_none")]
1292    pub refusal: Option<String>,
1293    /// Streaming reasoning content (DeepSeek-R1, QwQ models).
1294    /// Accepts either `reasoning_content` (DeepSeek / SGLang / TRT-LLM
1295    /// canonical) or `reasoning` (vLLM native / OpenRouter / OpenAI GPT-OSS)
1296    /// on input via the alias; output-side key selection is handled at the
1297    /// HTTP boundary by ai-dynamo/dynamo#11464's `RoutedReasoning` wrapper.
1298    #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
1299    pub reasoning_content: Option<String>,
1300}
1301
1302#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1303pub struct ChatCompletionStreamResponseDeltaFunctionCall {
1304    #[serde(skip_serializing_if = "Option::is_none")]
1305    pub name: Option<String>,
1306    #[serde(
1307        default,
1308        deserialize_with = "deserialize_arguments_opt",
1309        skip_serializing_if = "Option::is_none"
1310    )]
1311    pub arguments: Option<String>,
1312}
1313
1314/// Streaming chat choice.
1315#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1316pub struct ChatChoiceStream {
1317    pub index: u32,
1318    pub delta: ChatCompletionStreamResponseDelta,
1319    pub finish_reason: Option<FinishReason>,
1320    pub logprobs: Option<ChatChoiceLogprobs>,
1321}
1322
1323/// Streaming chat completion response with extended choices.
1324///
1325/// `service_tier`, `system_fingerprint`, and `usage` are optional in the
1326/// spec and omitted (not serialized as `null`) when absent. Note: with
1327/// `stream_options.include_usage`, OpenAI emits `"usage": null` on every
1328/// chunk before the final one; callers needing that exact shape must
1329/// inject the key at the HTTP boundary.
1330#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1331pub struct CreateChatCompletionStreamResponse {
1332    pub id: String,
1333    pub choices: Vec<ChatChoiceStream>,
1334    pub created: u32,
1335    pub model: String,
1336    #[serde(skip_serializing_if = "Option::is_none")]
1337    pub service_tier: Option<ServiceTierResponse>,
1338    #[serde(skip_serializing_if = "Option::is_none")]
1339    pub system_fingerprint: Option<String>,
1340    pub object: String,
1341    #[serde(
1342        skip_serializing_if = "Option::is_none",
1343        serialize_with = "serialize_usage_omitting_absent"
1344    )]
1345    pub usage: Option<CompletionUsage>,
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    #[test]
1353    fn stream_options_default_missing_and_null_flags_to_false() {
1354        for (payload, expected) in [
1355            (serde_json::json!({}), (false, false)),
1356            (
1357                serde_json::json!({
1358                    "include_usage": null,
1359                    "continuous_usage_stats": true,
1360                }),
1361                (false, true),
1362            ),
1363            (
1364                serde_json::json!({
1365                    "include_usage": true,
1366                    "continuous_usage_stats": null,
1367                }),
1368                (true, false),
1369            ),
1370        ] {
1371            let options: ChatCompletionStreamOptions = serde_json::from_value(payload).unwrap();
1372            assert_eq!(
1373                (options.include_usage, options.continuous_usage_stats),
1374                expected
1375            );
1376        }
1377    }
1378
1379    #[test]
1380    fn stream_options_preserve_boolean_wire_shape_and_reject_other_types() {
1381        let options: ChatCompletionStreamOptions = serde_json::from_value(serde_json::json!({
1382            "include_usage": true,
1383            "continuous_usage_stats": false,
1384        }))
1385        .unwrap();
1386        assert!(options.include_usage);
1387        assert!(!options.continuous_usage_stats);
1388        assert_eq!(
1389            serde_json::to_value(options).unwrap(),
1390            serde_json::json!({
1391                "include_usage": true,
1392                "continuous_usage_stats": false,
1393            })
1394        );
1395
1396        for payload in [
1397            serde_json::json!({"include_usage": "true"}),
1398            serde_json::json!({"continuous_usage_stats": 1}),
1399        ] {
1400            serde_json::from_value::<ChatCompletionStreamOptions>(payload).unwrap_err();
1401        }
1402    }
1403
1404    #[test]
1405    fn stop_accepts_token_id_array() {
1406        let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
1407
1408        assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
1409    }
1410
1411    #[test]
1412    fn stop_accepts_string_and_string_array() {
1413        let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
1414
1415        assert_eq!(stop, Stop::String(" The".to_string()));
1416
1417        let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
1418
1419        assert_eq!(
1420            stop,
1421            Stop::StringArray(vec!["A".to_string(), "B".to_string()])
1422        );
1423    }
1424
1425    #[test]
1426    fn stop_token_id_display_string_remains_string_stop() {
1427        let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
1428
1429        assert_eq!(stop, Stop::String("token_id:576".to_string()));
1430
1431        let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
1432
1433        assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
1434    }
1435
1436    #[test]
1437    fn stop_rejects_single_token_id() {
1438        let result = serde_json::from_value::<Stop>(serde_json::json!(576));
1439
1440        assert!(result.is_err());
1441    }
1442
1443    #[test]
1444    fn stop_converts_from_upstream_stop_configuration() {
1445        let upstream =
1446            async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
1447
1448        assert_eq!(
1449            Stop::from(upstream),
1450            Stop::StringArray(vec!["END".to_string()])
1451        );
1452    }
1453
1454    #[test]
1455    fn request_builder_accepts_upstream_reasoning_effort() {
1456        let request = CreateChatCompletionRequestArgs::default()
1457            .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
1458            .build()
1459            .unwrap();
1460
1461        assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
1462    }
1463
1464    #[test]
1465    fn tool_call_defaults_type_on_deserialize() {
1466        let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1467            "id": "call_123",
1468            "function": {
1469                "name": "get_weather",
1470                "arguments": "{\"location\":\"SF\"}"
1471            }
1472        }))
1473        .unwrap();
1474
1475        assert_eq!(tool_call.r#type, FunctionType::Function);
1476    }
1477
1478    #[test]
1479    fn tool_call_serializes_type_for_wire_compat() {
1480        let tool_call = ChatCompletionMessageToolCall {
1481            id: "call_123".into(),
1482            r#type: FunctionType::Function,
1483            function: FunctionCall {
1484                name: "get_weather".into(),
1485                arguments: "{\"location\":\"SF\"}".into(),
1486            },
1487        };
1488
1489        let json = serde_json::to_value(tool_call).unwrap();
1490        assert_eq!(json["type"], "function");
1491    }
1492
1493    // -- dict-format arguments tests --
1494
1495    #[test]
1496    fn function_call_accepts_string_arguments() {
1497        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1498            "name": "get_weather",
1499            "arguments": "{\"location\":\"SF\"}"
1500        }))
1501        .unwrap();
1502        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1503    }
1504
1505    #[test]
1506    fn function_call_accepts_dict_arguments() {
1507        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1508            "name": "get_weather",
1509            "arguments": {"location": "SF"}
1510        }))
1511        .unwrap();
1512        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1513    }
1514
1515    #[test]
1516    fn function_call_rejects_integer_arguments() {
1517        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1518            "name": "f",
1519            "arguments": 42
1520        }));
1521        assert!(result.is_err());
1522    }
1523
1524    #[test]
1525    fn function_call_rejects_boolean_arguments() {
1526        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1527            "name": "f",
1528            "arguments": true
1529        }));
1530        assert!(result.is_err());
1531    }
1532
1533    #[test]
1534    fn function_call_rejects_null_arguments() {
1535        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1536            "name": "f",
1537            "arguments": null
1538        }));
1539        assert!(result.is_err());
1540    }
1541
1542    #[test]
1543    fn function_call_rejects_array_arguments() {
1544        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1545            "name": "f",
1546            "arguments": [1, 2, 3]
1547        }));
1548        assert!(result.is_err());
1549    }
1550
1551    #[test]
1552    fn function_call_stream_null_arguments_produces_none() {
1553        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1554            "name": "f",
1555            "arguments": null
1556        }))
1557        .unwrap();
1558        assert_eq!(fcs.arguments, None);
1559    }
1560
1561    #[test]
1562    fn function_call_stream_rejects_integer_arguments() {
1563        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1564            "name": "f",
1565            "arguments": 42
1566        }));
1567        assert!(result.is_err());
1568    }
1569
1570    #[test]
1571    fn function_call_stream_rejects_boolean_arguments() {
1572        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1573            "name": "f",
1574            "arguments": true
1575        }));
1576        assert!(result.is_err());
1577    }
1578
1579    #[test]
1580    fn function_call_stream_accepts_dict_arguments() {
1581        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1582            "name": "get_weather",
1583            "arguments": {"location": "SF"}
1584        }))
1585        .unwrap();
1586        assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1587    }
1588
1589    #[test]
1590    fn function_call_stream_accepts_null_arguments() {
1591        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1592            "name": "get_weather"
1593        }))
1594        .unwrap();
1595        assert_eq!(fcs.arguments, None);
1596    }
1597
1598    #[test]
1599    fn tool_call_with_dict_arguments_roundtrip() {
1600        let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1601            "id": "call_abc",
1602            "type": "function",
1603            "function": {
1604                "name": "search",
1605                "arguments": {"query": "hello", "limit": 10}
1606            }
1607        }))
1608        .unwrap();
1609        // Compare as parsed JSON values since key order is non-deterministic
1610        let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1611        assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1612        // Re-serialisation produces a string, not an object
1613        let json = serde_json::to_value(&tc).unwrap();
1614        assert!(json["function"]["arguments"].is_string());
1615    }
1616
1617    #[test]
1618    fn stream_delta_function_call_accepts_dict_arguments() {
1619        let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1620            serde_json::from_value(serde_json::json!({
1621                "name": "get_weather",
1622                "arguments": {"location": "SF"}
1623            }))
1624            .unwrap();
1625        assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1626    }
1627
1628    fn parse_content_part(json: serde_json::Value) -> ChatCompletionRequestUserMessageContentPart {
1629        serde_json::from_value(json).expect("content part deserialization failed")
1630    }
1631
1632    #[test]
1633    fn image_url_url_and_top_level_uuid() {
1634        let part = parse_content_part(serde_json::json!({
1635            "type": "image_url",
1636            "image_url": {"url": "https://x.example/y.png"},
1637            "uuid": "image-123"
1638        }));
1639
1640        match part {
1641            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1642                assert_eq!(part.uuid.as_deref(), Some("image-123"));
1643                assert_eq!(
1644                    part.image_url.as_ref().map(|image| image.url.as_str()),
1645                    Some("https://x.example/y.png")
1646                );
1647            }
1648            _ => panic!("expected image_url part"),
1649        }
1650    }
1651
1652    #[test]
1653    fn image_url_null_and_top_level_uuid() {
1654        let part = parse_content_part(serde_json::json!({
1655            "type": "image_url",
1656            "image_url": null,
1657            "uuid": "sku-1234-a"
1658        }));
1659
1660        match part {
1661            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1662                assert!(part.image_url.is_none());
1663                assert_eq!(part.uuid.as_deref(), Some("sku-1234-a"));
1664            }
1665            _ => panic!("expected image_url part"),
1666        }
1667    }
1668
1669    #[test]
1670    fn empty_media_urls_deserialize_as_uuid_only() {
1671        for (part_type, media_field, uuid) in [
1672            ("image_url", "image_url", "image-cache-key"),
1673            ("video_url", "video_url", "video-cache-key"),
1674            ("audio_url", "audio_url", "audio-cache-key"),
1675        ] {
1676            let part = parse_content_part(serde_json::json!({
1677                "type": part_type,
1678                (media_field): {"url": ""},
1679                "uuid": uuid
1680            }));
1681            let json = serde_json::to_value(part).unwrap();
1682
1683            assert!(json[media_field].is_null());
1684            assert_eq!(json["uuid"], uuid);
1685        }
1686    }
1687
1688    #[test]
1689    fn image_url_null_without_uuid_deserializes_for_use_site_validation() {
1690        let part = parse_content_part(serde_json::json!({
1691            "type": "image_url",
1692            "image_url": null
1693        }));
1694
1695        match part {
1696            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1697                assert!(part.image_url.is_none());
1698                assert!(part.uuid.is_none());
1699            }
1700            _ => panic!("expected image_url part"),
1701        }
1702    }
1703
1704    #[test]
1705    fn image_url_serialize_uuid_only_uses_null_image_url() {
1706        let part = ChatCompletionRequestMessageContentPartImage {
1707            image_url: None,
1708            uuid: Some("image-123".to_string()),
1709        };
1710        let json = serde_json::to_value(part).unwrap();
1711
1712        assert!(json["image_url"].is_null());
1713        assert_eq!(json["uuid"], "image-123");
1714    }
1715
1716    #[test]
1717    fn cached_media_builders_allow_omitting_urls() {
1718        let image = ChatCompletionRequestMessageContentPartImageArgs::default()
1719            .uuid("image-123")
1720            .build()
1721            .unwrap();
1722        let video = ChatCompletionRequestMessageContentPartVideoArgs::default()
1723            .uuid("video-123")
1724            .build()
1725            .unwrap();
1726        let audio = ChatCompletionRequestMessageContentPartAudioUrlArgs::default()
1727            .uuid("audio-123")
1728            .build()
1729            .unwrap();
1730
1731        let image_json = serde_json::to_value(image).unwrap();
1732        let video_json = serde_json::to_value(video).unwrap();
1733        let audio_json = serde_json::to_value(audio).unwrap();
1734        assert!(image_json["image_url"].is_null());
1735        assert!(video_json["video_url"].is_null());
1736        assert!(audio_json["audio_url"].is_null());
1737    }
1738
1739    #[test]
1740    fn image_url_uuid_accepts_opaque_string() {
1741        let part = parse_content_part(serde_json::json!({
1742            "type": "image_url",
1743            "image_url": {"url": "https://x.example/y.png"},
1744            "uuid": "img-ac3921de680bb217"
1745        }));
1746
1747        match part {
1748            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1749                assert_eq!(part.uuid.as_deref(), Some("img-ac3921de680bb217"));
1750            }
1751            _ => panic!("expected image_url part"),
1752        }
1753    }
1754
1755    #[test]
1756    fn url_conversions_preserve_required_urls() {
1757        let image: ImageUrl = "https://x.example/image.png".into();
1758        let video: VideoUrl = "https://x.example/video.mp4".into();
1759        let audio: AudioUrl = "https://x.example/audio.wav".into();
1760
1761        assert_eq!(image.url.as_str(), "https://x.example/image.png");
1762        assert_eq!(video.url.as_str(), "https://x.example/video.mp4");
1763        assert_eq!(audio.url.as_str(), "https://x.example/audio.wav");
1764    }
1765
1766    #[test]
1767    fn invalid_media_urls_remain_rejected() {
1768        for (part_type, media_field) in [
1769            ("image_url", "image_url"),
1770            ("video_url", "video_url"),
1771            ("audio_url", "audio_url"),
1772        ] {
1773            let result = serde_json::from_value::<ChatCompletionRequestUserMessageContentPart>(
1774                serde_json::json!({
1775                    "type": part_type,
1776                    (media_field): {"url": "not a url"},
1777                    "uuid": "cache-key"
1778                }),
1779            );
1780
1781            assert!(result.is_err(), "{part_type} accepted an invalid URL");
1782        }
1783    }
1784
1785    #[test]
1786    fn legacy_nested_media_uuids_remain_accepted() {
1787        let legacy_uuid = "92b888ad-e64a-478f-b688-5091e16544e3";
1788
1789        for (part_type, media_field, url) in [
1790            ("image_url", "image_url", "https://x.example/image.png"),
1791            ("video_url", "video_url", "https://x.example/video.mp4"),
1792            ("audio_url", "audio_url", "https://x.example/audio.wav"),
1793        ] {
1794            let part = parse_content_part(serde_json::json!({
1795                "type": part_type,
1796                (media_field): {"url": url, "uuid": legacy_uuid}
1797            }));
1798            let json = serde_json::to_value(part).unwrap();
1799
1800            assert_eq!(json[media_field]["url"], url);
1801            assert_eq!(json[media_field]["uuid"], legacy_uuid);
1802            assert!(json.get("uuid").is_none());
1803        }
1804    }
1805
1806    #[test]
1807    fn video_url_null_and_top_level_uuid() {
1808        let part = parse_content_part(serde_json::json!({
1809            "type": "video_url",
1810            "video_url": null,
1811            "uuid": "video-cache-key"
1812        }));
1813
1814        match part {
1815            ChatCompletionRequestUserMessageContentPart::VideoUrl(part) => {
1816                assert!(part.video_url.is_none());
1817                assert_eq!(part.uuid.as_deref(), Some("video-cache-key"));
1818            }
1819            _ => panic!("expected video_url part"),
1820        }
1821    }
1822
1823    #[test]
1824    fn audio_url_null_and_top_level_uuid() {
1825        let part = parse_content_part(serde_json::json!({
1826            "type": "audio_url",
1827            "audio_url": null,
1828            "uuid": "audio-cache-key"
1829        }));
1830
1831        match part {
1832            ChatCompletionRequestUserMessageContentPart::AudioUrl(part) => {
1833                assert!(part.audio_url.is_none());
1834                assert_eq!(part.uuid.as_deref(), Some("audio-cache-key"));
1835            }
1836            _ => panic!("expected audio_url part"),
1837        }
1838    }
1839
1840    #[test]
1841    fn message_content_array_preserves_uuid_alignment() {
1842        let payload = serde_json::json!({
1843            "role": "user",
1844            "content": [
1845                {"type": "text", "text": "describe these"},
1846                {
1847                    "type": "image_url",
1848                    "image_url": {"url": "https://x.example/img1.png"},
1849                    "uuid": "image-1"
1850                },
1851                {"type": "image_url", "image_url": null, "uuid": "image-1"}
1852            ]
1853        });
1854        let message: ChatCompletionRequestUserMessage = serde_json::from_value(payload).unwrap();
1855        let ChatCompletionRequestUserMessageContent::Array(parts) = message.content else {
1856            panic!("expected content array");
1857        };
1858
1859        assert_eq!(parts.len(), 3);
1860        match &parts[1] {
1861            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1862                assert!(
1863                    part.image_url
1864                        .as_ref()
1865                        .map(|image| image.url.as_str())
1866                        .is_some()
1867                );
1868                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1869            }
1870            _ => panic!("parts[1] should be image_url"),
1871        }
1872        match &parts[2] {
1873            ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1874                assert!(part.image_url.is_none());
1875                assert_eq!(part.uuid.as_deref(), Some("image-1"));
1876            }
1877            _ => panic!("parts[2] should be image_url"),
1878        }
1879    }
1880
1881    #[test]
1882    fn tool_message_accepts_media_content() {
1883        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
1884            "role": "tool",
1885            "tool_call_id": "call_media",
1886            "content": [
1887                {"type": "text", "text": "Screenshot captured"},
1888                {
1889                    "type": "image_url",
1890                    "image_url": {
1891                        "url": "data:image/png;base64,aGVsbG8="
1892                    }
1893                },
1894                {
1895                    "type": "video_url",
1896                    "video_url": {
1897                        "url": "https://example.com/clip.mp4"
1898                    }
1899                },
1900                {
1901                    "type": "audio_url",
1902                    "audio_url": {
1903                        "url": "https://example.com/audio.wav"
1904                    }
1905                }
1906            ]
1907        }))
1908        .unwrap();
1909
1910        let ChatCompletionRequestMessage::Tool(tool) = message else {
1911            panic!("expected tool message");
1912        };
1913        let ChatCompletionRequestToolMessageContent::Array(parts) = tool.content else {
1914            panic!("expected array content");
1915        };
1916        assert!(matches!(
1917            parts[1],
1918            ChatCompletionRequestToolMessageContentPart::ImageUrl(_)
1919        ));
1920        assert!(matches!(
1921            parts[2],
1922            ChatCompletionRequestToolMessageContentPart::VideoUrl(_)
1923        ));
1924        assert!(matches!(
1925            parts[3],
1926            ChatCompletionRequestToolMessageContentPart::AudioUrl(_)
1927        ));
1928    }
1929
1930    #[test]
1931    fn chat_logprob_serializes_token_id_when_present() {
1932        let logprob = ChatCompletionTokenLogprob {
1933            token: " hello".into(),
1934            logprob: -0.12,
1935            token_id: Some(123),
1936            bytes: Some(vec![32, 104, 101, 108, 108, 111]),
1937            top_logprobs: vec![],
1938        };
1939
1940        let json = serde_json::to_value(logprob).unwrap();
1941
1942        assert_eq!(json["token_id"], 123);
1943    }
1944
1945    #[test]
1946    fn chat_logprob_deserializes_optional_fields() {
1947        let choice_logprobs: ChatChoiceLogprobs = serde_json::from_value(serde_json::json!({
1948            "content": [{
1949                "token": " hello",
1950                "logprob": -0.12,
1951                "top_logprobs": []
1952            }]
1953        }))
1954        .unwrap();
1955        let token_logprob: ChatCompletionTokenLogprob = serde_json::from_value(serde_json::json!({
1956            "token": " hello",
1957            "logprob": -0.12,
1958            "token_id": 123,
1959            "bytes": [32, 104, 101, 108, 108, 111],
1960            "top_logprobs": []
1961        }))
1962        .unwrap();
1963
1964        assert_eq!(choice_logprobs.content.as_ref().unwrap()[0].token_id, None);
1965        assert!(choice_logprobs.refusal.is_none());
1966        assert_eq!(token_logprob.token_id, Some(123));
1967        assert_eq!(token_logprob.bytes, Some(vec![32, 104, 101, 108, 108, 111]));
1968    }
1969
1970    #[test]
1971    fn chat_logprob_preserves_nullable_fields() {
1972        let choice_logprobs = ChatChoiceLogprobs {
1973            content: None,
1974            refusal: None,
1975        };
1976        let token_logprob = ChatCompletionTokenLogprob {
1977            token: " hello".into(),
1978            logprob: -0.12,
1979            token_id: None,
1980            bytes: None,
1981            top_logprobs: vec![],
1982        };
1983
1984        let choice_json = serde_json::to_value(choice_logprobs).unwrap();
1985        let token_json = serde_json::to_value(token_logprob).unwrap();
1986
1987        assert_eq!(choice_json["content"], serde_json::Value::Null);
1988        assert_eq!(choice_json["refusal"], serde_json::Value::Null);
1989        assert!(token_json.get("token_id").is_none());
1990        assert_eq!(token_json["bytes"], serde_json::Value::Null);
1991    }
1992
1993    #[test]
1994    #[allow(deprecated)]
1995    fn chat_response_omits_absent_optional_fields() {
1996        let response = CreateChatCompletionResponse {
1997            id: "chatcmpl_dummy".into(),
1998            choices: vec![ChatChoice {
1999                index: 0,
2000                message: ChatCompletionResponseMessage {
2001                    content: Some(ChatCompletionMessageContent::Text("hello".into())),
2002                    refusal: None,
2003                    tool_calls: None,
2004                    role: Role::Assistant,
2005                    function_call: None,
2006                    audio: None,
2007                    reasoning_content: None,
2008                },
2009                finish_reason: Some(FinishReason::Stop),
2010                logprobs: None,
2011            }],
2012            created: 0,
2013            model: "dummy-model".into(),
2014            service_tier: None,
2015            system_fingerprint: None,
2016            object: "chat.completion".into(),
2017            usage: None,
2018        };
2019
2020        let json = serde_json::to_value(response).unwrap();
2021
2022        for absent in ["usage", "service_tier", "system_fingerprint"] {
2023            assert!(json.get(absent).is_none(), "{absent} should be omitted");
2024        }
2025        let choice = &json["choices"][0];
2026        assert_eq!(choice["finish_reason"], "stop");
2027        assert_eq!(choice["logprobs"], serde_json::Value::Null);
2028        let message = &choice["message"];
2029        assert_eq!(message["refusal"], serde_json::Value::Null);
2030        for absent in ["tool_calls", "function_call", "audio", "reasoning_content"] {
2031            assert!(
2032                message.get(absent).is_none(),
2033                "message.{absent} should be omitted"
2034            );
2035        }
2036    }
2037
2038    #[test]
2039    fn stream_response_omits_absent_optional_fields() {
2040        let chunk = CreateChatCompletionStreamResponse {
2041            id: "chatcmpl_dummy".into(),
2042            choices: vec![ChatChoiceStream {
2043                index: 0,
2044                delta: ChatCompletionStreamResponseDelta {
2045                    content: Some(ChatCompletionMessageContent::Text("hello".into())),
2046                    function_call: None,
2047                    tool_calls: None,
2048                    role: None,
2049                    refusal: None,
2050                    reasoning_content: None,
2051                },
2052                finish_reason: None,
2053                logprobs: None,
2054            }],
2055            created: 0,
2056            model: "dummy-model".into(),
2057            service_tier: None,
2058            system_fingerprint: None,
2059            object: "chat.completion.chunk".into(),
2060            usage: None,
2061        };
2062
2063        let json = serde_json::to_value(chunk).unwrap();
2064
2065        for absent in ["usage", "service_tier", "system_fingerprint"] {
2066            assert!(json.get(absent).is_none(), "{absent} should be omitted");
2067        }
2068    }
2069
2070    #[test]
2071    fn stream_tool_call_continuation_chunk_omits_absent_fields() {
2072        let chunk = ChatCompletionMessageToolCallChunk {
2073            index: 0,
2074            id: None,
2075            r#type: None,
2076            function: Some(FunctionCallStream {
2077                name: None,
2078                arguments: Some("{\"a\":".into()),
2079            }),
2080        };
2081
2082        let json = serde_json::to_value(chunk).unwrap();
2083
2084        assert!(json.get("id").is_none());
2085        assert!(json.get("type").is_none());
2086        assert!(json["function"].get("name").is_none());
2087        assert_eq!(json["function"]["arguments"], "{\"a\":");
2088    }
2089
2090    #[test]
2091    fn stream_delta_function_call_omits_absent_fields() {
2092        let function_call = ChatCompletionStreamResponseDeltaFunctionCall {
2093            name: None,
2094            arguments: Some("{}".into()),
2095        };
2096
2097        let json = serde_json::to_value(function_call).unwrap();
2098
2099        assert!(json.get("name").is_none());
2100        assert_eq!(json["arguments"], "{}");
2101    }
2102
2103    #[test]
2104    fn usage_details_omit_absent_fields() {
2105        let response = CreateChatCompletionResponse {
2106            id: "chatcmpl_dummy".into(),
2107            choices: vec![],
2108            created: 0,
2109            model: "dummy-model".into(),
2110            service_tier: None,
2111            system_fingerprint: None,
2112            object: "chat.completion".into(),
2113            usage: Some(CompletionUsage {
2114                prompt_tokens: 10,
2115                completion_tokens: 25,
2116                total_tokens: 35,
2117                prompt_tokens_details: Some(PromptTokensDetails {
2118                    audio_tokens: None,
2119                    cached_tokens: Some(0),
2120                }),
2121                completion_tokens_details: Some(CompletionTokensDetails {
2122                    reasoning_tokens: Some(5),
2123                    ..Default::default()
2124                }),
2125            }),
2126        };
2127
2128        let json = serde_json::to_value(&response).unwrap();
2129        let usage = &json["usage"];
2130
2131        assert_eq!(usage["total_tokens"], 35);
2132        assert_eq!(usage["prompt_tokens_details"]["cached_tokens"], 0);
2133        assert!(
2134            usage["prompt_tokens_details"].get("audio_tokens").is_none(),
2135            "audio_tokens should be omitted, not null"
2136        );
2137        assert_eq!(usage["completion_tokens_details"]["reasoning_tokens"], 5);
2138        for absent in [
2139            "accepted_prediction_tokens",
2140            "audio_tokens",
2141            "rejected_prediction_tokens",
2142        ] {
2143            assert!(
2144                usage["completion_tokens_details"].get(absent).is_none(),
2145                "{absent} should be omitted"
2146            );
2147        }
2148
2149        let roundtrip: CreateChatCompletionResponse = serde_json::from_value(json).unwrap();
2150        assert_eq!(roundtrip, response);
2151    }
2152
2153    // -- Kimi-style system tools / assistant partial tests --
2154
2155    #[test]
2156    fn effective_tool_set_unions_top_level_and_dynamic_system_tools() {
2157        let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2158            "model": "dummy-kimi-model",
2159            "tools": [{
2160                "type": "function",
2161                "function": {"name": "add", "parameters": {"type": "object"}}
2162            }],
2163            "messages": [
2164                {"role": "user", "content": "start"},
2165                {
2166                    "role": "system",
2167                    "tools": [
2168                        {
2169                            "type": "function",
2170                            "function": {"name": "lookup", "parameters": {"type": "object"}}
2171                        },
2172                        {"name": "search", "parameters": {"type": "object"}},
2173                        {"description": "no name, skipped"}
2174                    ]
2175                },
2176                {"role": "user", "content": "continue"}
2177            ]
2178        }))
2179        .unwrap();
2180
2181        assert!(request.has_effective_tools());
2182        assert_eq!(request.dynamic_system_tools().count(), 3);
2183        assert_eq!(
2184            request.effective_tool_names().collect::<Vec<_>>(),
2185            ["add", "lookup", "search"],
2186            "top-level first, then dynamic in message order; wrapped and bare shapes both resolve"
2187        );
2188        for name in ["add", "lookup", "search"] {
2189            assert!(
2190                request.effective_tool_contains(name),
2191                "{name} should be found"
2192            );
2193        }
2194        assert!(!request.effective_tool_contains("missing"));
2195        assert!(
2196            !request.effective_tool_contains("no name, skipped"),
2197            "a description is not a name"
2198        );
2199    }
2200
2201    #[test]
2202    fn effective_tool_set_is_empty_without_any_declaration() {
2203        for payload in [
2204            serde_json::json!({
2205                "model": "m",
2206                "messages": [{"role": "user", "content": "hi"}]
2207            }),
2208            serde_json::json!({
2209                "model": "m",
2210                "tools": [],
2211                "messages": [{"role": "system", "content": "plain system text"}]
2212            }),
2213        ] {
2214            let request: CreateChatCompletionRequest = serde_json::from_value(payload).unwrap();
2215            assert!(!request.has_effective_tools());
2216            assert_eq!(request.effective_tool_names().count(), 0);
2217            assert!(!request.effective_tool_contains("anything"));
2218        }
2219    }
2220
2221    #[test]
2222    fn dynamic_system_tools_alone_count_as_effective_tools() {
2223        let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2224            "model": "dummy-kimi-model",
2225            "messages": [
2226                {"role": "system", "tools": [{"name": "lookup"}]},
2227                {"role": "user", "content": "go"}
2228            ]
2229        }))
2230        .unwrap();
2231
2232        assert!(
2233            request.tools.is_none(),
2234            "nothing was folded into top-level tools"
2235        );
2236        assert!(request.has_effective_tools());
2237        assert!(request.effective_tool_contains("lookup"));
2238    }
2239
2240    #[test]
2241    fn dynamic_tool_name_handles_wrapped_bare_and_invalid_shapes() {
2242        assert_eq!(
2243            dynamic_tool_name(&serde_json::json!({"type": "function", "function": {"name": "a"}})),
2244            Some("a")
2245        );
2246        assert_eq!(
2247            dynamic_tool_name(&serde_json::json!({"name": "b"})),
2248            Some("b")
2249        );
2250        assert_eq!(dynamic_tool_name(&serde_json::json!({"name": 7})), None);
2251    }
2252
2253    #[test]
2254    fn system_message_without_content_is_rejected_unless_it_declares_tools() {
2255        // Same leading text as upstream's derived error, so clients and
2256        // tests matching on "missing field `content`" keep working.
2257        for (label, message) in [
2258            ("nothing", serde_json::json!({"role": "system"})),
2259            (
2260                "empty tools",
2261                serde_json::json!({"role": "system", "tools": []}),
2262            ),
2263        ] {
2264            let error =
2265                serde_json::from_value::<ChatCompletionRequestMessage>(message).expect_err(label);
2266            assert!(
2267                error.to_string().starts_with("missing field `content`"),
2268                "{label}: unexpected error {error}"
2269            );
2270        }
2271    }
2272
2273    #[test]
2274    fn system_message_guard_leaves_valid_shapes_alone() {
2275        for (label, message) in [
2276            (
2277                "content only",
2278                serde_json::json!({"role": "system", "content": "hi"}),
2279            ),
2280            (
2281                "content parts",
2282                serde_json::json!({"role": "system", "content": [{"type": "text", "text": "hi"}]}),
2283            ),
2284            (
2285                "tools only",
2286                serde_json::json!({"role": "system", "tools": [{"name": "lookup"}]}),
2287            ),
2288            (
2289                "content and tools (renderer decides)",
2290                serde_json::json!({"role": "system", "content": "hi", "tools": [{"name": "lookup"}]}),
2291            ),
2292        ] {
2293            let parsed: ChatCompletionRequestMessage =
2294                serde_json::from_value(message).unwrap_or_else(|e| panic!("{label}: {e}"));
2295            assert!(
2296                matches!(parsed, ChatCompletionRequestMessage::System(_)),
2297                "{label}"
2298            );
2299        }
2300    }
2301
2302    #[test]
2303    fn message_rejects_tools_and_partial_on_wrong_roles() {
2304        let tools = serde_json::json!([{"name": "lookup"}]);
2305        for (label, message, needle) in [
2306            (
2307                "tools on user",
2308                serde_json::json!({"role": "user", "content": "hi", "tools": tools}),
2309                "`tools` is only accepted on system messages, not on role user",
2310            ),
2311            (
2312                "tools on assistant",
2313                serde_json::json!({"role": "assistant", "content": "hi", "tools": tools}),
2314                "`tools` is only accepted on system messages, not on role assistant",
2315            ),
2316            (
2317                // Upstream type without a `tools` field: accepting would drop them.
2318                "tools on developer",
2319                serde_json::json!({"role": "developer", "content": "hi", "tools": tools}),
2320                "`tools` is only accepted on system messages, not on role developer",
2321            ),
2322            (
2323                "partial on user",
2324                serde_json::json!({"role": "user", "content": "hi", "partial": true}),
2325                "`partial` is only accepted on assistant messages, not on role user",
2326            ),
2327            (
2328                "partial on system",
2329                serde_json::json!({"role": "system", "content": "hi", "partial": false}),
2330                "`partial` is only accepted on assistant messages, not on role system",
2331            ),
2332        ] {
2333            let error = serde_json::from_value::<ChatCompletionRequestMessage>(message)
2334                .expect_err(label)
2335                .to_string();
2336            assert!(error.contains(needle), "{label}: {error}");
2337        }
2338
2339        for message in [
2340            serde_json::json!({"role": "user", "content": "hi", "tools": null}),
2341            serde_json::json!({"role": "user", "content": "hi", "partial": null}),
2342        ] {
2343            serde_json::from_value::<ChatCompletionRequestMessage>(message).unwrap();
2344        }
2345
2346        for message in [
2347            serde_json::json!({"role": "system", "tools": tools}),
2348            serde_json::json!({"role": "assistant", "content": "seed", "partial": true}),
2349            serde_json::json!({"role": "user", "content": "hi", "x_vendor": 1}),
2350        ] {
2351            serde_json::from_value::<ChatCompletionRequestMessage>(message).unwrap();
2352        }
2353    }
2354
2355    #[test]
2356    fn message_rejects_duplicate_top_level_keys() {
2357        for (label, raw) in [
2358            (
2359                "role twice",
2360                r#"{"role":"user","content":"hi","role":"system"}"#,
2361            ),
2362            (
2363                "content twice",
2364                r#"{"role":"user","content":"a","content":"b"}"#,
2365            ),
2366        ] {
2367            let error = serde_json::from_str::<ChatCompletionRequestMessage>(raw)
2368                .expect_err(label)
2369                .to_string();
2370            assert!(error.contains("duplicate field"), "{label}: {error}");
2371        }
2372    }
2373
2374    #[test]
2375    fn message_rejects_duplicate_fields_in_nested_typed_objects() {
2376        let tool_call = r#"{
2377            "role":"assistant",
2378            "content":null,
2379            "tool_calls":[{
2380                "id":"first",
2381                "id":"second",
2382                "type":"function",
2383                "function":{"name":"lookup","arguments":"{}"}
2384            }]
2385        }"#;
2386        let error = serde_json::from_str::<ChatCompletionRequestMessage>(tool_call)
2387            .unwrap_err()
2388            .to_string();
2389        assert!(error.contains("duplicate field `id`"), "{error}");
2390
2391        let content_part = r#"{
2392            "role":"user",
2393            "content":[{"type":"text","text":"first","text":"second"}]
2394        }"#;
2395        assert!(serde_json::from_str::<ChatCompletionRequestMessage>(content_part).is_err());
2396    }
2397
2398    #[test]
2399    fn default_system_message_round_trips() {
2400        let message = ChatCompletionRequestSystemMessage::default();
2401        let json = serde_json::to_value(&message).unwrap();
2402        assert_eq!(json, serde_json::json!({"content": ""}));
2403        let back: ChatCompletionRequestSystemMessage = serde_json::from_value(json).unwrap();
2404        assert_eq!(back, message);
2405
2406        let built = ChatCompletionRequestSystemMessageArgs::default()
2407            .name("ops")
2408            .build()
2409            .unwrap();
2410        let json = serde_json::to_value(&built).unwrap();
2411        assert_eq!(json, serde_json::json!({"content": "", "name": "ops"}));
2412        serde_json::from_value::<ChatCompletionRequestSystemMessage>(json).unwrap();
2413    }
2414
2415    #[test]
2416    fn system_message_guard_keeps_field_level_errors() {
2417        let error = serde_json::from_value::<ChatCompletionRequestMessage>(serde_json::json!({
2418            "role": "system",
2419            "tools": "lookup"
2420        }))
2421        .unwrap_err();
2422        assert!(
2423            !error.to_string().starts_with("missing field `content`"),
2424            "field error expected, got {error}"
2425        );
2426    }
2427
2428    #[test]
2429    fn system_message_canonicalizes_missing_content_with_tools() {
2430        let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2431            "model": "dummy-kimi-model",
2432            "messages": [
2433                {
2434                    "role": "system",
2435                    "tools": [
2436                        {
2437                            "name": "lookup",
2438                            "description": "dummy lookup tool",
2439                            "parameters": {
2440                                "type": "object",
2441                                "properties": {
2442                                    "query": { "type": "string" }
2443                                }
2444                            }
2445                        }
2446                    ]
2447                },
2448                {
2449                    "role": "assistant",
2450                    "content": "synthetic prefill",
2451                    "partial": true
2452                },
2453                {
2454                    "role": "user",
2455                    "content": "continue"
2456                }
2457            ]
2458        }))
2459        .unwrap();
2460
2461        match &request.messages[0] {
2462            ChatCompletionRequestMessage::System(system) => {
2463                assert_eq!(
2464                    system.content,
2465                    ChatCompletionRequestSystemMessageContent::Text(String::new())
2466                );
2467                let tools = system.tools.as_ref().expect("tools should be present");
2468                assert_eq!(tools.len(), 1);
2469                assert_eq!(tools[0]["name"], "lookup");
2470            }
2471            other => panic!("expected system message, got {other:?}"),
2472        }
2473
2474        match &request.messages[1] {
2475            ChatCompletionRequestMessage::Assistant(assistant) => {
2476                assert_eq!(assistant.partial, Some(true));
2477            }
2478            other => panic!("expected assistant message, got {other:?}"),
2479        }
2480
2481        // Explicit null has the same wire meaning as omission. Both serialize
2482        // to the canonical required-content shape.
2483        let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
2484            "role": "system",
2485            "content": null,
2486            "tools": [{"name": "lookup"}]
2487        }))
2488        .unwrap();
2489        let ChatCompletionRequestMessage::System(system) = &message else {
2490            panic!("expected system message");
2491        };
2492        assert_eq!(
2493            system.content,
2494            ChatCompletionRequestSystemMessageContent::Text(String::new())
2495        );
2496        assert_eq!(
2497            serde_json::to_value(message).unwrap(),
2498            serde_json::json!({
2499                "role": "system",
2500                "content": "",
2501                "tools": [{"name": "lookup"}]
2502            })
2503        );
2504    }
2505
2506    #[test]
2507    fn kimi_style_request_preserves_tools_and_canonicalizes_content() {
2508        let payload = serde_json::json!({
2509            "model": "dummy-kimi-model",
2510            "messages": [
2511                {
2512                    "role": "system",
2513                    "tools": [
2514                        {
2515                            "name": "lookup",
2516                            "description": "dummy lookup tool",
2517                            "parameters": {
2518                                "type": "object",
2519                                "properties": {
2520                                    "query": { "type": "string" }
2521                                }
2522                            },
2523                            "vendor_hint": { "priority": 3 }
2524                        }
2525                    ]
2526                },
2527                {
2528                    "role": "assistant",
2529                    "content": "synthetic prefill",
2530                    "partial": true
2531                },
2532                {
2533                    "role": "user",
2534                    "content": "continue"
2535                }
2536            ]
2537        });
2538
2539        let request: CreateChatCompletionRequest = serde_json::from_value(payload.clone()).unwrap();
2540        let serialized = serde_json::to_value(request).unwrap();
2541        let mut canonical = payload;
2542        canonical["messages"][0]["content"] = serde_json::json!("");
2543
2544        assert_eq!(serialized, canonical);
2545    }
2546
2547    #[test]
2548    fn system_message_tools_preserve_official_wrapped_shape() {
2549        let payload = serde_json::json!({
2550            "model": "dummy-kimi-model",
2551            "messages": [
2552                {
2553                    "role": "system",
2554                    "tools": [
2555                        {
2556                            "type": "function",
2557                            "function": {
2558                                "name": "lookup",
2559                                "description": "dummy lookup tool",
2560                                "parameters": {
2561                                    "type": "object",
2562                                    "properties": {
2563                                        "query": { "type": "string" }
2564                                    },
2565                                    "required": ["query"]
2566                                },
2567                                "strict": true
2568                            }
2569                        }
2570                    ]
2571                },
2572                { "role": "user", "content": "continue" }
2573            ]
2574        });
2575
2576        let request: CreateChatCompletionRequest = serde_json::from_value(payload.clone()).unwrap();
2577        match &request.messages[0] {
2578            ChatCompletionRequestMessage::System(system) => {
2579                let tools = system.tools.as_ref().expect("tools should be present");
2580                assert_eq!(tools[0]["type"], "function");
2581                assert_eq!(tools[0]["function"]["name"], "lookup");
2582            }
2583            other => panic!("expected system message, got {other:?}"),
2584        }
2585
2586        let mut canonical = payload;
2587        canonical["messages"][0]["content"] = serde_json::json!("");
2588        assert_eq!(serde_json::to_value(request).unwrap(), canonical);
2589    }
2590
2591    #[test]
2592    fn assistant_message_omits_partial_when_absent() {
2593        let assistant = ChatCompletionRequestAssistantMessageArgs::default()
2594            .content("hello")
2595            .build()
2596            .unwrap();
2597
2598        assert_eq!(assistant.partial, None);
2599        let json = serde_json::to_value(&assistant).unwrap();
2600        assert!(
2601            json.get("partial").is_none(),
2602            "partial should be omitted when absent"
2603        );
2604    }
2605
2606    #[test]
2607    fn assistant_message_serializes_partial_when_present() {
2608        let assistant = ChatCompletionRequestAssistantMessageArgs::default()
2609            .content("synthetic prefill")
2610            .partial(true)
2611            .build()
2612            .unwrap();
2613
2614        let json = serde_json::to_value(&assistant).unwrap();
2615        assert_eq!(json["partial"], true);
2616
2617        let roundtrip: ChatCompletionRequestAssistantMessage =
2618            serde_json::from_value(json).unwrap();
2619        assert_eq!(roundtrip, assistant);
2620    }
2621
2622    #[test]
2623    fn system_message_from_upstream_preserves_content_and_leaves_tools_none() {
2624        let upstream = async_openai::types::chat::ChatCompletionRequestSystemMessage {
2625            content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
2626                "hi".into(),
2627            ),
2628            name: None,
2629        };
2630
2631        let owned: ChatCompletionRequestSystemMessage = upstream.into();
2632        assert!(owned.tools.is_none());
2633        match owned.content {
2634            ChatCompletionRequestSystemMessageContent::Text(text) => assert_eq!(text, "hi"),
2635            other => panic!("expected text content, got {other:?}"),
2636        }
2637    }
2638
2639    #[test]
2640    fn system_message_restores_upstream_convenience_conversions() {
2641        let from_content = ChatCompletionRequestSystemMessage::from(
2642            ChatCompletionRequestSystemMessageContent::Text("from content".into()),
2643        );
2644        let from_str = ChatCompletionRequestSystemMessage::from("from str");
2645        let from_string = ChatCompletionRequestSystemMessage::from(String::from("from string"));
2646
2647        for (message, expected) in [
2648            (from_content, "from content"),
2649            (from_str, "from str"),
2650            (from_string, "from string"),
2651        ] {
2652            assert_eq!(
2653                message.content,
2654                ChatCompletionRequestSystemMessageContent::Text(expected.into())
2655            );
2656            assert!(message.name.is_none());
2657            assert!(message.tools.is_none());
2658        }
2659    }
2660}