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