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    ChatChoiceLogprobs,
26    ChatCompletionAudio,
27    ChatCompletionAudioFormat,
28    ChatCompletionAudioVoice,
29    ChatCompletionFunctionCall,
30    ChatCompletionFunctions,
31    ChatCompletionFunctionsArgs,
32    ChatCompletionRequestAssistantMessageAudio,
33    ChatCompletionRequestAssistantMessageContent,
34    ChatCompletionRequestAssistantMessageContentPart,
35    ChatCompletionRequestDeveloperMessage,
36    ChatCompletionRequestDeveloperMessageArgs,
37    ChatCompletionRequestDeveloperMessageContent,
38    ChatCompletionRequestFunctionMessage,
39    ChatCompletionRequestFunctionMessageArgs,
40    ChatCompletionRequestMessageContentPartAudio,
41    ChatCompletionRequestMessageContentPartRefusal,
42    ChatCompletionRequestMessageContentPartText,
43    ChatCompletionRequestSystemMessage,
44    // Builder types (generated by derive_builder)
45    ChatCompletionRequestSystemMessageArgs,
46    ChatCompletionRequestSystemMessageContent,
47    ChatCompletionRequestSystemMessageContentPart,
48    ChatCompletionRequestToolMessage,
49    ChatCompletionRequestToolMessageArgs,
50    ChatCompletionRequestToolMessageContent,
51    ChatCompletionRequestToolMessageContentPart,
52    ChatCompletionResponseMessageAudio,
53    ChatCompletionTokenLogprob,
54    Choice,
55    CompletionFinishReason,
56    CompletionTokensDetails,
57    CompletionUsage,
58    FunctionObject,
59    FunctionObjectArgs,
60    InputAudio,
61    InputAudioFormat,
62    Logprobs,
63    PredictionContent,
64    PredictionContentContent,
65    Prompt,
66    PromptTokensDetails,
67    ResponseFormat,
68    ResponseFormatJsonSchema,
69    Role,
70    ServiceTier,
71    TopLogprobs,
72    WebSearchContextSize,
73    WebSearchLocation,
74    WebSearchOptions,
75    WebSearchUserLocation,
76    WebSearchUserLocationType,
77};
78
79/// OpenAI stop configuration, with Dynamo's token-id stop extension.
80///
81/// The standard OpenAI shape accepts a string or string array. Dynamo also
82/// accepts an integer array, e.g. `"stop": [576]`, to express token-id stop
83/// conditions for tokenized in/out workflows. Strings like `"token_id:576"`
84/// remain ordinary string stops; the `token_id:<id>` format is only an output
85/// display format for logprobs.
86#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
87#[serde(untagged)]
88pub enum Stop {
89    String(String),
90    StringArray(Vec<String>),
91    TokenIdArray(Vec<u32>),
92}
93
94impl Stop {
95    pub fn strings(&self) -> Option<Vec<String>> {
96        match self {
97            Stop::String(s) => Some(vec![s.clone()]),
98            Stop::StringArray(arr) => Some(arr.clone()),
99            Stop::TokenIdArray(_) => None,
100        }
101    }
102
103    pub fn token_ids(&self) -> Option<Vec<u32>> {
104        match self {
105            Stop::TokenIdArray(arr) => Some(arr.clone()),
106            Stop::String(_) | Stop::StringArray(_) => None,
107        }
108    }
109}
110
111impl From<String> for Stop {
112    fn from(value: String) -> Self {
113        Stop::String(value)
114    }
115}
116
117impl From<&str> for Stop {
118    fn from(value: &str) -> Self {
119        Stop::String(value.to_string())
120    }
121}
122
123impl From<Vec<String>> for Stop {
124    fn from(value: Vec<String>) -> Self {
125        Stop::StringArray(value)
126    }
127}
128
129impl From<Vec<u32>> for Stop {
130    fn from(value: Vec<u32>) -> Self {
131        Stop::TokenIdArray(value)
132    }
133}
134
135impl From<async_openai::types::chat::StopConfiguration> for Stop {
136    fn from(value: async_openai::types::chat::StopConfiguration) -> Self {
137        match value {
138            async_openai::types::chat::StopConfiguration::String(value) => Stop::String(value),
139            async_openai::types::chat::StopConfiguration::StringArray(value) => {
140                Stop::StringArray(value)
141            }
142        }
143    }
144}
145
146// Upstream renamed FinishReason (streaming) -- re-export
147pub use async_openai::types::chat::FinishReason;
148
149// Upstream uses FunctionType where we used ChatCompletionToolType.
150// Re-export both names for compatibility.
151pub use async_openai::types::chat::FunctionType;
152
153/// Reasoning effort values accepted by OpenAI-compatible clients.
154///
155/// async-openai versions used by some Dynamo builds do not include `max`, but
156/// DeepSeek-V4 compatible clients may send it by default. Keep this local enum
157/// wire-compatible with upstream values and include `max`.
158#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "lowercase")]
160pub enum ReasoningEffort {
161    None,
162    Minimal,
163    Low,
164    Medium,
165    High,
166    Xhigh,
167    Max,
168}
169
170impl From<async_openai::types::chat::ReasoningEffort> for ReasoningEffort {
171    fn from(value: async_openai::types::chat::ReasoningEffort) -> Self {
172        match value {
173            async_openai::types::chat::ReasoningEffort::None => ReasoningEffort::None,
174            async_openai::types::chat::ReasoningEffort::Minimal => ReasoningEffort::Minimal,
175            async_openai::types::chat::ReasoningEffort::Low => ReasoningEffort::Low,
176            async_openai::types::chat::ReasoningEffort::Medium => ReasoningEffort::Medium,
177            async_openai::types::chat::ReasoningEffort::High => ReasoningEffort::High,
178            async_openai::types::chat::ReasoningEffort::Xhigh => ReasoningEffort::Xhigh,
179        }
180    }
181}
182
183// ---------------------------------------------------------------------------
184// Flexible `arguments` deserialisation helpers
185// ---------------------------------------------------------------------------
186// Some agent frameworks (e.g. LangChain, custom harnesses) send tool-call
187// arguments as a pre-parsed JSON object instead of the canonical JSON
188// string.  The helpers below normalise both representations to a `String` so
189// downstream code never needs to branch on the wire format.
190
191fn deserialize_arguments<'de, D>(deserializer: D) -> Result<String, D::Error>
192where
193    D: serde::Deserializer<'de>,
194{
195    use serde::de::Error;
196    let value = serde_json::Value::deserialize(deserializer)?;
197    match value {
198        serde_json::Value::String(s) => Ok(s),
199        v @ serde_json::Value::Object(_) => {
200            // serde_json::to_string on a Value is infallible
201            Ok(serde_json::to_string(&v).unwrap())
202        }
203        other => Err(D::Error::custom(format!(
204            "expected string or object for `arguments`, got {other}"
205        ))),
206    }
207}
208
209fn deserialize_arguments_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
210where
211    D: serde::Deserializer<'de>,
212{
213    use serde::de::Error;
214    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
215    match value {
216        None => Ok(None),
217        Some(serde_json::Value::String(s)) => Ok(Some(s)),
218        Some(v @ serde_json::Value::Object(_)) => serde_json::to_string(&v)
219            .map(Some)
220            .map_err(|e| D::Error::custom(e.to_string())),
221        Some(other) => Err(D::Error::custom(format!(
222            "expected string or object for `arguments`, got {other}"
223        ))),
224    }
225}
226
227// ---------------------------------------------------------------------------
228// FunctionCall / FunctionCallStream — local definitions with flexible deser
229// ---------------------------------------------------------------------------
230// Upstream `async-openai` only accepts a JSON string for `arguments`.
231// We define these locally so we can attach `#[serde(deserialize_with)]` and
232// accept both string and object representations on the wire.
233
234/// The name and arguments of a function that should be called.
235///
236/// Accepts `arguments` as either a JSON string (`"{\"key\":\"value\"}"`) or a
237/// JSON object (`{"key": "value"}`); both are normalised to a JSON string
238/// on deserialisation so callers always see the canonical form.
239#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
240pub struct FunctionCall {
241    pub name: String,
242    #[serde(deserialize_with = "deserialize_arguments")]
243    pub arguments: String,
244}
245
246/// Streaming variant of [`FunctionCall`] where both fields are optional.
247#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
248pub struct FunctionCallStream {
249    pub name: Option<String>,
250    #[serde(default, deserialize_with = "deserialize_arguments_opt")]
251    pub arguments: Option<String>,
252}
253
254/// Streaming tool-call chunk.
255///
256/// Defined locally (instead of re-exporting from upstream) because its
257/// `function` field references our local [`FunctionCallStream`] with the
258/// flexible `arguments` deserialiser.
259#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
260pub struct ChatCompletionMessageToolCallChunk {
261    pub index: u32,
262    pub id: Option<String>,
263    pub r#type: Option<FunctionType>,
264    pub function: Option<FunctionCallStream>,
265}
266
267// ---------------------------------------------------------------------------
268// Types with structural differences from upstream (kept locally)
269// ---------------------------------------------------------------------------
270
271/// Image detail level. Kept locally because upstream uses different field types in ImageUrl.
272#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq)]
273#[serde(rename_all = "lowercase")]
274pub enum ImageDetail {
275    #[default]
276    Auto,
277    Low,
278    High,
279}
280
281/// Image content part -- uses our extended `ImageUrl` with `url::Url` and `uuid`.
282#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
283#[builder(name = "ChatCompletionRequestMessageContentPartImageArgs")]
284#[builder(pattern = "mutable")]
285#[builder(setter(into, strip_option))]
286#[builder(derive(Debug))]
287#[builder(build_fn(error = "OpenAIError"))]
288pub struct ChatCompletionRequestMessageContentPartImage {
289    pub image_url: ImageUrl,
290}
291
292/// Image URL with `url::Url` type and optional UUID.
293///
294/// Differs from upstream: uses `url::Url` instead of `String`, adds `uuid` field
295/// for tracking multimodal assets through the pipeline.
296#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
297#[builder(name = "ImageUrlArgs")]
298#[builder(pattern = "mutable")]
299#[builder(setter(into, strip_option))]
300#[builder(derive(Debug))]
301#[builder(build_fn(error = "OpenAIError"))]
302pub struct ImageUrl {
303    pub url: Url,
304    pub detail: Option<ImageDetail>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub uuid: Option<Uuid>,
307}
308
309#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
310#[serde(rename_all = "lowercase")]
311pub enum ChatCompletionToolType {
312    #[default]
313    Function,
314}
315
316#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
317pub struct FunctionName {
318    pub name: String,
319}
320
321#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
322pub struct ChatCompletionNamedToolChoice {
323    pub r#type: ChatCompletionToolType,
324    pub function: FunctionName,
325}
326
327fn default_function_type() -> FunctionType {
328    FunctionType::Function
329}
330
331/// Tool call kept locally to preserve `type: "function"` in unary request/response payloads.
332///
333/// Differs from upstream: `type` is serialized by default and also defaults to
334/// `function` when omitted during deserialization, preserving compatibility with
335/// both Dynamo's historical wire format and upstream spec-compliant inputs.
336#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
337pub struct ChatCompletionMessageToolCall {
338    pub id: String,
339    #[serde(default = "default_function_type")]
340    pub r#type: FunctionType,
341    pub function: FunctionCall,
342}
343
344/// Tool choice enum kept locally because upstream changed variant names.
345#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
346#[serde(rename_all = "lowercase")]
347pub enum ChatCompletionToolChoiceOption {
348    #[default]
349    None,
350    Auto,
351    Required,
352    #[serde(untagged)]
353    Named(ChatCompletionNamedToolChoice),
354}
355
356#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
357#[builder(name = "ChatCompletionToolArgs")]
358#[builder(pattern = "mutable")]
359#[builder(setter(into, strip_option), default)]
360#[builder(derive(Debug))]
361#[builder(build_fn(error = "OpenAIError"))]
362pub struct ChatCompletionTool {
363    #[builder(default = "ChatCompletionToolType::Function")]
364    pub r#type: ChatCompletionToolType,
365    pub function: FunctionObject,
366}
367
368// ---------------------------------------------------------------------------
369// Inference-serving extensions (not in upstream)
370// ---------------------------------------------------------------------------
371
372/// Matched stop condition from the backend.
373///
374/// Inference backends (vLLM, SGLang) report which stop condition triggered:
375/// - `String`: a matched user-provided stop sequence
376/// - `Int`: a matched stop token ID
377/// - `IntArray`: matched stop token IDs reported as a sequence
378#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
379#[serde(untagged)]
380pub enum StopReason {
381    String(String),
382    Int(i64),
383    IntArray(Vec<i64>),
384}
385
386/// Reasoning content from a previous assistant turn.
387///
388/// Deserializes from either:
389/// - A plain string: `"reasoning_content": "thinking..."` -> `Text("thinking...")`
390/// - An array of strings: `"reasoning_content": ["seg1", "seg2"]` -> `Segments(["seg1", "seg2"])`
391///
392/// The `Segments` variant preserves interleaved reasoning order needed for KV cache-correct
393/// context reconstruction. `segments[i]` is the reasoning that preceded `tool_calls[i]`;
394/// `segments[tool_calls.len()]` is any trailing reasoning after the last tool call.
395#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
396#[serde(untagged)]
397pub enum ReasoningContent {
398    /// Flat string -- single reasoning block or legacy backward-compat form.
399    Text(String),
400    /// Interleaved segments. segments[i] precedes tool_calls[i];
401    /// segments[N] is trailing reasoning after the last tool call.
402    Segments(Vec<String>),
403}
404
405impl ReasoningContent {
406    /// Join all segments (or return text as-is) into a single flat string.
407    pub fn to_flat_string(&self) -> String {
408        match self {
409            ReasoningContent::Text(s) => s.clone(),
410            ReasoningContent::Segments(segs) => segs
411                .iter()
412                .filter(|s| !s.is_empty())
413                .cloned()
414                .collect::<Vec<_>>()
415                .join("\n"),
416        }
417    }
418
419    /// Returns the segments if this is the `Segments` variant, `None` for `Text`.
420    pub fn segments(&self) -> Option<&[String]> {
421        match self {
422            ReasoningContent::Segments(segs) => Some(segs),
423            ReasoningContent::Text(_) => None,
424        }
425    }
426}
427
428// -- Multimodal content types for responses (not in upstream) --
429
430/// Response content part for text in assistant messages
431#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
432pub struct ChatCompletionResponseContentPartText {
433    pub text: String,
434}
435
436/// Response content part for image URLs in assistant messages
437#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
438pub struct ChatCompletionResponseContentPartImageUrl {
439    pub image_url: ImageUrlResponse,
440}
441
442/// Response content part for video URLs in assistant messages
443#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
444pub struct ChatCompletionResponseContentPartVideoUrl {
445    pub video_url: VideoUrlResponse,
446}
447
448/// Response content part for audio URLs in assistant messages
449#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
450pub struct ChatCompletionResponseContentPartAudioUrl {
451    pub audio_url: AudioUrlResponse,
452}
453
454#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
455pub struct ImageUrlResponse {
456    pub url: String,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub detail: Option<String>,
459}
460
461#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
462pub struct VideoUrlResponse {
463    pub url: String,
464}
465
466#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
467pub struct AudioUrlResponse {
468    pub url: String,
469}
470
471/// Content parts for assistant responses supporting multiple modalities
472#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
473#[serde(tag = "type", rename_all = "snake_case")]
474pub enum ChatCompletionResponseContentPart {
475    Text(ChatCompletionResponseContentPartText),
476    ImageUrl(ChatCompletionResponseContentPartImageUrl),
477    VideoUrl(ChatCompletionResponseContentPartVideoUrl),
478    AudioUrl(ChatCompletionResponseContentPartAudioUrl),
479}
480
481/// Assistant message content -- can be a simple string or multimodal content parts.
482///
483/// Upstream uses `Option<String>` for the content field. We extend this to
484/// support multimodal responses (text + images + video + audio) from backends
485/// like vLLM that can return non-text content.
486#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
487#[serde(untagged)]
488pub enum ChatCompletionMessageContent {
489    /// Simple text content (backward compatible)
490    Text(String),
491    /// Array of content parts (for multimodal responses)
492    Parts(Vec<ChatCompletionResponseContentPart>),
493}
494
495// -- Multimodal input types (video/audio URL support, not in upstream) --
496
497#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
498#[builder(name = "VideoUrlArgs")]
499#[builder(pattern = "mutable")]
500#[builder(setter(into, strip_option))]
501#[builder(derive(Debug))]
502#[builder(build_fn(error = "OpenAIError"))]
503pub struct VideoUrl {
504    pub url: Url,
505    pub detail: Option<ImageDetail>,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub uuid: Option<Uuid>,
508}
509
510#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
511#[builder(name = "ChatCompletionRequestMessageContentPartVideoArgs")]
512#[builder(pattern = "mutable")]
513#[builder(setter(into, strip_option))]
514#[builder(derive(Debug))]
515#[builder(build_fn(error = "OpenAIError"))]
516pub struct ChatCompletionRequestMessageContentPartVideo {
517    pub video_url: VideoUrl,
518}
519
520#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
521#[builder(name = "AudioUrlArgs")]
522#[builder(pattern = "mutable")]
523#[builder(setter(into, strip_option))]
524#[builder(derive(Debug))]
525#[builder(build_fn(error = "OpenAIError"))]
526pub struct AudioUrl {
527    pub url: Url,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub uuid: Option<Uuid>,
530}
531
532#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
533#[builder(name = "ChatCompletionRequestMessageContentPartAudioUrlArgs")]
534#[builder(pattern = "mutable")]
535#[builder(setter(into, strip_option))]
536#[builder(derive(Debug))]
537#[builder(build_fn(error = "OpenAIError"))]
538pub struct ChatCompletionRequestMessageContentPartAudioUrl {
539    pub audio_url: AudioUrl,
540}
541
542// -- Extended request/response types --
543
544/// User message content -- references our extended content part enum.
545#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
546#[serde(untagged)]
547pub enum ChatCompletionRequestUserMessageContent {
548    Text(String),
549    Array(Vec<ChatCompletionRequestUserMessageContentPart>),
550}
551
552#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
553#[builder(name = "ChatCompletionRequestUserMessageArgs")]
554#[builder(pattern = "mutable")]
555#[builder(setter(into, strip_option), default)]
556#[builder(derive(Debug))]
557#[builder(build_fn(error = "OpenAIError"))]
558pub struct ChatCompletionRequestUserMessage {
559    pub content: ChatCompletionRequestUserMessageContent,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub name: Option<String>,
562}
563
564impl Default for ChatCompletionRequestUserMessageContent {
565    fn default() -> Self {
566        Self::Text(String::new())
567    }
568}
569
570impl From<&str> for ChatCompletionRequestUserMessageContent {
571    fn from(value: &str) -> Self {
572        Self::Text(value.into())
573    }
574}
575
576impl From<String> for ChatCompletionRequestUserMessageContent {
577    fn from(value: String) -> Self {
578        Self::Text(value)
579    }
580}
581
582impl From<Vec<ChatCompletionRequestUserMessageContentPart>>
583    for ChatCompletionRequestUserMessageContent
584{
585    fn from(value: Vec<ChatCompletionRequestUserMessageContentPart>) -> Self {
586        Self::Array(value)
587    }
588}
589
590/// User message content part with video and audio URL support.
591///
592/// Extends upstream `ChatCompletionRequestUserMessageContentPart` with:
593/// - `VideoUrl`: video input for multimodal models
594/// - `AudioUrl`: audio URL input (distinct from base64 InputAudio)
595#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
596#[serde(tag = "type")]
597#[serde(rename_all = "snake_case")]
598pub enum ChatCompletionRequestUserMessageContentPart {
599    Text(ChatCompletionRequestMessageContentPartText),
600    ImageUrl(ChatCompletionRequestMessageContentPartImage),
601    VideoUrl(ChatCompletionRequestMessageContentPartVideo),
602    AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
603    InputAudio(ChatCompletionRequestMessageContentPartAudio),
604}
605
606/// Assistant message with reasoning content support.
607///
608/// Extends upstream `ChatCompletionRequestAssistantMessage` with:
609/// - `reasoning_content`: interleaved reasoning segments for KV cache correctness
610///   (DeepSeek-R1, QwQ models)
611#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
612#[builder(name = "ChatCompletionRequestAssistantMessageArgs")]
613#[builder(pattern = "mutable")]
614#[builder(setter(into, strip_option), default)]
615#[builder(derive(Debug))]
616#[builder(build_fn(error = "OpenAIError"))]
617pub struct ChatCompletionRequestAssistantMessage {
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub content: Option<ChatCompletionRequestAssistantMessageContent>,
620    /// Reasoning content from a previous assistant turn.
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub reasoning_content: Option<ReasoningContent>,
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub refusal: Option<String>,
625    #[serde(skip_serializing_if = "Option::is_none")]
626    pub name: Option<String>,
627    #[serde(skip_serializing_if = "Option::is_none")]
628    pub audio: Option<ChatCompletionRequestAssistantMessageAudio>,
629    #[serde(skip_serializing_if = "Option::is_none")]
630    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
631    #[deprecated]
632    #[serde(skip_serializing_if = "Option::is_none")]
633    pub function_call: Option<FunctionCall>,
634}
635
636/// Chat completion request message enum.
637///
638/// Redefined to use our extended `ChatCompletionRequestAssistantMessage`
639/// (with reasoning_content) and `ChatCompletionRequestUserMessage`
640/// (which references our extended content parts with video/audio).
641#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
642#[serde(tag = "role")]
643#[serde(rename_all = "lowercase")]
644pub enum ChatCompletionRequestMessage {
645    Developer(ChatCompletionRequestDeveloperMessage),
646    System(ChatCompletionRequestSystemMessage),
647    User(ChatCompletionRequestUserMessage),
648    Assistant(ChatCompletionRequestAssistantMessage),
649    Tool(ChatCompletionRequestToolMessage),
650    Function(ChatCompletionRequestFunctionMessage),
651}
652
653/// Response tier enum for responses (distinct from request `ServiceTier`).
654///
655/// Not in upstream -- backends report which tier actually served the request.
656#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
657#[serde(rename_all = "lowercase")]
658pub enum ServiceTierResponse {
659    Scale,
660    Default,
661    Flex,
662    Priority,
663}
664
665/// Chat completion response message with multimodal content and reasoning.
666///
667/// Extends upstream `ChatCompletionResponseMessage` with:
668/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
669/// - `reasoning_content`: model reasoning output (DeepSeek-R1, QwQ)
670#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
671pub struct ChatCompletionResponseMessage {
672    /// Always serialized (as `null` when None) so clients can rely on the
673    /// `content` key being present alongside `reasoning_content` or
674    /// `tool_calls`. Matches the upstream OpenAI API shape (DGH-651).
675    pub content: Option<ChatCompletionMessageContent>,
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub refusal: Option<String>,
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
680    pub role: Role,
681    #[serde(skip_serializing_if = "Option::is_none")]
682    #[deprecated]
683    pub function_call: Option<FunctionCall>,
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub audio: Option<ChatCompletionResponseMessageAudio>,
686    /// Reasoning content produced by the model (DeepSeek-R1, QwQ).
687    pub reasoning_content: Option<String>,
688}
689
690/// Stream options with per-chunk usage reporting.
691///
692/// Extends upstream `ChatCompletionStreamOptions` with:
693/// - `continuous_usage_stats`: emit usage in every chunk, not just the final one
694#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
695pub struct ChatCompletionStreamOptions {
696    pub include_usage: bool,
697    /// When true, usage statistics are included in every streaming chunk.
698    /// Backends like vLLM/SGLang support this for real-time token counting.
699    #[serde(default)]
700    pub continuous_usage_stats: bool,
701}
702
703/// Chat completion request with multimodal processor support.
704///
705/// Extends upstream `CreateChatCompletionRequest` with:
706/// - `mm_processor_kwargs`: multimodal processor configuration (vLLM-specific)
707/// - Uses our extended `ChatCompletionRequestMessage` (with reasoning, video/audio)
708/// - Uses our extended `ChatCompletionStreamOptions` (with continuous_usage_stats)
709#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
710#[builder(name = "CreateChatCompletionRequestArgs")]
711#[builder(pattern = "mutable")]
712#[builder(setter(into, strip_option), default)]
713#[builder(derive(Debug))]
714#[builder(build_fn(error = "OpenAIError"))]
715pub struct CreateChatCompletionRequest {
716    pub messages: Vec<ChatCompletionRequestMessage>,
717    pub model: String,
718    /// Multimodal processor configuration (vLLM-specific)
719    #[serde(skip_serializing_if = "Option::is_none")]
720    pub mm_processor_kwargs: Option<serde_json::Value>,
721    #[serde(skip_serializing_if = "Option::is_none")]
722    pub store: Option<bool>,
723    #[serde(skip_serializing_if = "Option::is_none")]
724    pub reasoning_effort: Option<ReasoningEffort>,
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub metadata: Option<serde_json::Value>,
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub frequency_penalty: Option<f32>,
729    #[serde(skip_serializing_if = "Option::is_none")]
730    pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub logprobs: Option<bool>,
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub top_logprobs: Option<u8>,
735    #[deprecated]
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub max_tokens: Option<u32>,
738    #[serde(skip_serializing_if = "Option::is_none")]
739    pub max_completion_tokens: Option<u32>,
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub n: Option<u8>,
742    #[serde(skip_serializing_if = "Option::is_none")]
743    pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
744    #[serde(skip_serializing_if = "Option::is_none")]
745    pub prediction: Option<PredictionContent>,
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub audio: Option<ChatCompletionAudio>,
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub presence_penalty: Option<f32>,
750    #[serde(skip_serializing_if = "Option::is_none")]
751    pub response_format: Option<ResponseFormat>,
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub seed: Option<i64>,
754    #[serde(skip_serializing_if = "Option::is_none")]
755    pub service_tier: Option<ServiceTier>,
756    #[serde(skip_serializing_if = "Option::is_none")]
757    pub stop: Option<Stop>,
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub stream: Option<bool>,
760    #[serde(skip_serializing_if = "Option::is_none")]
761    pub stream_options: Option<ChatCompletionStreamOptions>,
762    #[serde(skip_serializing_if = "Option::is_none")]
763    pub temperature: Option<f32>,
764    #[serde(skip_serializing_if = "Option::is_none")]
765    pub top_p: Option<f32>,
766    #[serde(skip_serializing_if = "Option::is_none")]
767    pub tools: Option<Vec<ChatCompletionTool>>,
768    #[serde(skip_serializing_if = "Option::is_none")]
769    pub tool_choice: Option<ChatCompletionToolChoiceOption>,
770    #[serde(skip_serializing_if = "Option::is_none")]
771    pub parallel_tool_calls: Option<bool>,
772    #[serde(skip_serializing_if = "Option::is_none")]
773    pub user: Option<String>,
774    #[deprecated]
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub function_call: Option<ChatCompletionFunctionCall>,
777    #[deprecated]
778    #[serde(skip_serializing_if = "Option::is_none")]
779    pub functions: Option<Vec<ChatCompletionFunctions>>,
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub web_search_options: Option<WebSearchOptions>,
782}
783
784/// Chat choice with extended response message.
785///
786/// Uses our `ChatCompletionResponseMessage` (multimodal content + reasoning).
787#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
788pub struct ChatChoice {
789    pub index: u32,
790    pub message: ChatCompletionResponseMessage,
791    pub finish_reason: Option<FinishReason>,
792    pub logprobs: Option<ChatChoiceLogprobs>,
793}
794
795/// Non-streaming chat completion response.
796#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
797pub struct CreateChatCompletionResponse {
798    pub id: String,
799    pub choices: Vec<ChatChoice>,
800    pub created: u32,
801    pub model: String,
802    pub service_tier: Option<ServiceTierResponse>,
803    pub system_fingerprint: Option<String>,
804    pub object: String,
805    pub usage: Option<CompletionUsage>,
806}
807
808pub type ChatCompletionResponseStream =
809    Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
810
811/// Streaming delta with reasoning content.
812///
813/// Extends upstream `ChatCompletionStreamResponseDelta` with:
814/// - `content`: `Option<ChatCompletionMessageContent>` (multimodal) instead of `Option<String>`
815/// - `reasoning_content`: streaming reasoning tokens (DeepSeek-R1, QwQ)
816#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
817pub struct ChatCompletionStreamResponseDelta {
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub content: Option<ChatCompletionMessageContent>,
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
822    #[serde(skip_serializing_if = "Option::is_none")]
823    pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub role: Option<Role>,
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub refusal: Option<String>,
828    /// Streaming reasoning content (DeepSeek-R1, QwQ models).
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub reasoning_content: Option<String>,
831}
832
833#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
834pub struct ChatCompletionStreamResponseDeltaFunctionCall {
835    pub name: Option<String>,
836    #[serde(default, deserialize_with = "deserialize_arguments_opt")]
837    pub arguments: Option<String>,
838}
839
840/// Streaming chat choice.
841#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
842pub struct ChatChoiceStream {
843    pub index: u32,
844    pub delta: ChatCompletionStreamResponseDelta,
845    pub finish_reason: Option<FinishReason>,
846    pub logprobs: Option<ChatChoiceLogprobs>,
847}
848
849/// Streaming chat completion response with extended choices.
850#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
851pub struct CreateChatCompletionStreamResponse {
852    pub id: String,
853    pub choices: Vec<ChatChoiceStream>,
854    pub created: u32,
855    pub model: String,
856    pub service_tier: Option<ServiceTierResponse>,
857    pub system_fingerprint: Option<String>,
858    pub object: String,
859    pub usage: Option<CompletionUsage>,
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    #[test]
867    fn stop_accepts_token_id_array() {
868        let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
869
870        assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
871    }
872
873    #[test]
874    fn stop_accepts_string_and_string_array() {
875        let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
876
877        assert_eq!(stop, Stop::String(" The".to_string()));
878
879        let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
880
881        assert_eq!(
882            stop,
883            Stop::StringArray(vec!["A".to_string(), "B".to_string()])
884        );
885    }
886
887    #[test]
888    fn stop_token_id_display_string_remains_string_stop() {
889        let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
890
891        assert_eq!(stop, Stop::String("token_id:576".to_string()));
892
893        let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
894
895        assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
896    }
897
898    #[test]
899    fn stop_rejects_single_token_id() {
900        let result = serde_json::from_value::<Stop>(serde_json::json!(576));
901
902        assert!(result.is_err());
903    }
904
905    #[test]
906    fn stop_converts_from_upstream_stop_configuration() {
907        let upstream =
908            async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
909
910        assert_eq!(
911            Stop::from(upstream),
912            Stop::StringArray(vec!["END".to_string()])
913        );
914    }
915
916    #[test]
917    fn request_builder_accepts_upstream_reasoning_effort() {
918        let request = CreateChatCompletionRequestArgs::default()
919            .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
920            .build()
921            .unwrap();
922
923        assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
924    }
925
926    #[test]
927    fn tool_call_defaults_type_on_deserialize() {
928        let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
929            "id": "call_123",
930            "function": {
931                "name": "get_weather",
932                "arguments": "{\"location\":\"SF\"}"
933            }
934        }))
935        .unwrap();
936
937        assert_eq!(tool_call.r#type, FunctionType::Function);
938    }
939
940    #[test]
941    fn tool_call_serializes_type_for_wire_compat() {
942        let tool_call = ChatCompletionMessageToolCall {
943            id: "call_123".into(),
944            r#type: FunctionType::Function,
945            function: FunctionCall {
946                name: "get_weather".into(),
947                arguments: "{\"location\":\"SF\"}".into(),
948            },
949        };
950
951        let json = serde_json::to_value(tool_call).unwrap();
952        assert_eq!(json["type"], "function");
953    }
954
955    // -- dict-format arguments tests --
956
957    #[test]
958    fn function_call_accepts_string_arguments() {
959        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
960            "name": "get_weather",
961            "arguments": "{\"location\":\"SF\"}"
962        }))
963        .unwrap();
964        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
965    }
966
967    #[test]
968    fn function_call_accepts_dict_arguments() {
969        let fc: FunctionCall = serde_json::from_value(serde_json::json!({
970            "name": "get_weather",
971            "arguments": {"location": "SF"}
972        }))
973        .unwrap();
974        assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
975    }
976
977    #[test]
978    fn function_call_rejects_integer_arguments() {
979        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
980            "name": "f",
981            "arguments": 42
982        }));
983        assert!(result.is_err());
984    }
985
986    #[test]
987    fn function_call_rejects_boolean_arguments() {
988        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
989            "name": "f",
990            "arguments": true
991        }));
992        assert!(result.is_err());
993    }
994
995    #[test]
996    fn function_call_rejects_null_arguments() {
997        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
998            "name": "f",
999            "arguments": null
1000        }));
1001        assert!(result.is_err());
1002    }
1003
1004    #[test]
1005    fn function_call_rejects_array_arguments() {
1006        let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1007            "name": "f",
1008            "arguments": [1, 2, 3]
1009        }));
1010        assert!(result.is_err());
1011    }
1012
1013    #[test]
1014    fn function_call_stream_null_arguments_produces_none() {
1015        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1016            "name": "f",
1017            "arguments": null
1018        }))
1019        .unwrap();
1020        assert_eq!(fcs.arguments, None);
1021    }
1022
1023    #[test]
1024    fn function_call_stream_rejects_integer_arguments() {
1025        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1026            "name": "f",
1027            "arguments": 42
1028        }));
1029        assert!(result.is_err());
1030    }
1031
1032    #[test]
1033    fn function_call_stream_rejects_boolean_arguments() {
1034        let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1035            "name": "f",
1036            "arguments": true
1037        }));
1038        assert!(result.is_err());
1039    }
1040
1041    #[test]
1042    fn function_call_stream_accepts_dict_arguments() {
1043        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1044            "name": "get_weather",
1045            "arguments": {"location": "SF"}
1046        }))
1047        .unwrap();
1048        assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1049    }
1050
1051    #[test]
1052    fn function_call_stream_accepts_null_arguments() {
1053        let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1054            "name": "get_weather"
1055        }))
1056        .unwrap();
1057        assert_eq!(fcs.arguments, None);
1058    }
1059
1060    #[test]
1061    fn tool_call_with_dict_arguments_roundtrip() {
1062        let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1063            "id": "call_abc",
1064            "type": "function",
1065            "function": {
1066                "name": "search",
1067                "arguments": {"query": "hello", "limit": 10}
1068            }
1069        }))
1070        .unwrap();
1071        // Compare as parsed JSON values since key order is non-deterministic
1072        let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1073        assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1074        // Re-serialisation produces a string, not an object
1075        let json = serde_json::to_value(&tc).unwrap();
1076        assert!(json["function"]["arguments"].is_string());
1077    }
1078
1079    #[test]
1080    fn stream_delta_function_call_accepts_dict_arguments() {
1081        let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1082            serde_json::from_value(serde_json::json!({
1083                "name": "get_weather",
1084                "arguments": {"location": "SF"}
1085            }))
1086            .unwrap();
1087        assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1088    }
1089}