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