1use 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
18pub 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#[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
56#[cfg(feature = "protocol-schema")]
58impl utoipa::PartialSchema for Stop {
59 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
60 utoipa::openapi::schema::AnyOfBuilder::new()
61 .description(Some(
62 "OpenAI stop configuration, with Dynamo's token-id stop extension.",
63 ))
64 .item(String::schema())
65 .item(Vec::<String>::schema())
66 .item(Vec::<u32>::schema())
67 .into()
68 }
69}
70
71#[cfg(feature = "protocol-schema")]
72impl utoipa::ToSchema for Stop {
73 fn name() -> std::borrow::Cow<'static, str> {
74 "dynamo_protocols.chat.Stop".into()
75 }
76}
77
78impl Stop {
79 pub fn strings(&self) -> Option<Vec<String>> {
80 match self {
81 Stop::String(s) => Some(vec![s.clone()]),
82 Stop::StringArray(arr) => Some(arr.clone()),
83 Stop::TokenIdArray(_) => None,
84 }
85 }
86
87 pub fn token_ids(&self) -> Option<Vec<u32>> {
88 match self {
89 Stop::TokenIdArray(arr) => Some(arr.clone()),
90 Stop::String(_) | Stop::StringArray(_) => None,
91 }
92 }
93}
94
95impl From<String> for Stop {
96 fn from(value: String) -> Self {
97 Stop::String(value)
98 }
99}
100
101impl From<&str> for Stop {
102 fn from(value: &str) -> Self {
103 Stop::String(value.to_string())
104 }
105}
106
107impl From<Vec<String>> for Stop {
108 fn from(value: Vec<String>) -> Self {
109 Stop::StringArray(value)
110 }
111}
112
113impl From<Vec<u32>> for Stop {
114 fn from(value: Vec<u32>) -> Self {
115 Stop::TokenIdArray(value)
116 }
117}
118
119impl From<async_openai::types::chat::StopConfiguration> for Stop {
120 fn from(value: async_openai::types::chat::StopConfiguration) -> Self {
121 match value {
122 async_openai::types::chat::StopConfiguration::String(value) => Stop::String(value),
123 async_openai::types::chat::StopConfiguration::StringArray(value) => {
124 Stop::StringArray(value)
125 }
126 }
127 }
128}
129
130pub use async_openai::types::chat::FinishReason;
132
133pub use async_openai::types::chat::FunctionType;
136
137#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "lowercase")]
144#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
145#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ReasoningEffort))]
146pub enum ReasoningEffort {
147 None,
148 Minimal,
149 Low,
150 Medium,
151 High,
152 Xhigh,
153 Max,
154}
155
156impl From<async_openai::types::chat::ReasoningEffort> for ReasoningEffort {
157 fn from(value: async_openai::types::chat::ReasoningEffort) -> Self {
158 match value {
159 async_openai::types::chat::ReasoningEffort::None => ReasoningEffort::None,
160 async_openai::types::chat::ReasoningEffort::Minimal => ReasoningEffort::Minimal,
161 async_openai::types::chat::ReasoningEffort::Low => ReasoningEffort::Low,
162 async_openai::types::chat::ReasoningEffort::Medium => ReasoningEffort::Medium,
163 async_openai::types::chat::ReasoningEffort::High => ReasoningEffort::High,
164 async_openai::types::chat::ReasoningEffort::Xhigh => ReasoningEffort::Xhigh,
165 async_openai::types::chat::ReasoningEffort::Max => ReasoningEffort::Max,
166 }
167 }
168}
169
170fn deserialize_arguments<'de, D>(deserializer: D) -> Result<String, D::Error>
179where
180 D: serde::Deserializer<'de>,
181{
182 use serde::de::Error;
183 let value = serde_json::Value::deserialize(deserializer)?;
184 match value {
185 serde_json::Value::String(s) => Ok(s),
186 v @ serde_json::Value::Object(_) => {
187 Ok(serde_json::to_string(&v).unwrap())
189 }
190 other => Err(D::Error::custom(format!(
191 "expected string or object for `arguments`, got {other}"
192 ))),
193 }
194}
195
196fn deserialize_arguments_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
197where
198 D: serde::Deserializer<'de>,
199{
200 use serde::de::Error;
201 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
202 match value {
203 None => Ok(None),
204 Some(serde_json::Value::String(s)) => Ok(Some(s)),
205 Some(v @ serde_json::Value::Object(_)) => serde_json::to_string(&v)
206 .map(Some)
207 .map_err(|e| D::Error::custom(e.to_string())),
208 Some(other) => Err(D::Error::custom(format!(
209 "expected string or object for `arguments`, got {other}"
210 ))),
211 }
212}
213
214fn deserialize_optional_media<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
220where
221 D: serde::Deserializer<'de>,
222 T: serde::de::DeserializeOwned,
223{
224 use serde::de::Error;
225 match Option::<serde_json::Value>::deserialize(deserializer)? {
226 None => Ok(None),
227 Some(value) if value.get("url").and_then(serde_json::Value::as_str) == Some("") => Ok(None),
228 Some(value) => serde_json::from_value(value)
229 .map(Some)
230 .map_err(D::Error::custom),
231 }
232}
233
234#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
247#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
248#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::FunctionCall))]
249pub struct FunctionCall {
250 pub name: String,
251 #[serde(deserialize_with = "deserialize_arguments")]
252 pub arguments: String,
253}
254
255#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
259#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
260#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::FunctionCallStream))]
261pub struct FunctionCallStream {
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub name: Option<String>,
264 #[serde(
265 default,
266 skip_serializing_if = "Option::is_none",
267 deserialize_with = "deserialize_arguments_opt"
268 )]
269 pub arguments: Option<String>,
270}
271
272#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
278#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
279#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionMessageToolCallChunk))]
280pub struct ChatCompletionMessageToolCallChunk {
281 pub index: u32,
282 #[serde(skip_serializing_if = "Option::is_none")]
285 pub id: Option<String>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::FunctionType>))]
288 pub r#type: Option<FunctionType>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub function: Option<FunctionCallStream>,
291}
292
293#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
306#[builder(name = "ChatCompletionRequestMessageContentPartImageArgs")]
307#[builder(pattern = "mutable")]
308#[builder(setter(into, strip_option))]
309#[builder(derive(Debug))]
310#[builder(build_fn(error = "OpenAIError"))]
311#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
312#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessageContentPartImage))]
313pub struct ChatCompletionRequestMessageContentPartImage {
314 #[builder(default)]
315 #[serde(default, deserialize_with = "deserialize_optional_media")]
316 pub image_url: Option<ImageUrl>,
317 #[builder(default)]
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub uuid: Option<String>,
321}
322
323#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
328#[builder(name = "ImageUrlArgs")]
329#[builder(pattern = "mutable")]
330#[builder(setter(into, strip_option))]
331#[builder(derive(Debug))]
332#[builder(build_fn(error = "OpenAIError"))]
333#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
334#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ImageUrl))]
335pub struct ImageUrl {
336 pub url: Url,
337 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ImageDetail>))]
338 pub detail: Option<ImageDetail>,
339 #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
340 #[serde(skip_serializing_if = "Option::is_none")]
341 pub uuid: Option<Uuid>,
342}
343
344#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
350#[serde(tag = "type")]
351#[serde(rename_all = "snake_case")]
352#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
353#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestToolMessageContentPart))]
354pub enum ChatCompletionRequestToolMessageContentPart {
355 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestMessageContentPartText))]
356 Text(ChatCompletionRequestMessageContentPartText),
357 ImageUrl(ChatCompletionRequestMessageContentPartImage),
358 VideoUrl(ChatCompletionRequestMessageContentPartVideo),
359 AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
360}
361
362#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
364#[serde(untagged)]
365#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
366#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestToolMessageContent))]
367pub enum ChatCompletionRequestToolMessageContent {
368 Text(String),
369 Array(Vec<ChatCompletionRequestToolMessageContentPart>),
370}
371
372impl Default for ChatCompletionRequestToolMessageContent {
373 fn default() -> Self {
374 Self::Text(String::new())
375 }
376}
377
378impl From<&str> for ChatCompletionRequestToolMessageContent {
379 fn from(value: &str) -> Self {
380 Self::Text(value.into())
381 }
382}
383
384impl From<String> for ChatCompletionRequestToolMessageContent {
385 fn from(value: String) -> Self {
386 Self::Text(value)
387 }
388}
389
390#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
392#[builder(name = "ChatCompletionRequestToolMessageArgs")]
393#[builder(pattern = "mutable")]
394#[builder(setter(into, strip_option), default)]
395#[builder(derive(Debug))]
396#[builder(build_fn(error = "OpenAIError"))]
397#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
398#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestToolMessage))]
399pub struct ChatCompletionRequestToolMessage {
400 pub content: ChatCompletionRequestToolMessageContent,
401 pub tool_call_id: String,
402}
403
404#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
405#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
406#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatChoiceLogprobs))]
407pub struct ChatChoiceLogprobs {
408 #[cfg_attr(feature = "protocol-schema", schema(required))]
409 pub content: Option<Vec<ChatCompletionTokenLogprob>>,
410 #[cfg_attr(feature = "protocol-schema", schema(required))]
411 pub refusal: Option<Vec<ChatCompletionTokenLogprob>>,
412}
413
414#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
420#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
421#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionTokenLogprob))]
422pub struct ChatCompletionTokenLogprob {
423 pub token: String,
424 pub logprob: f32,
425 #[serde(skip_serializing_if = "Option::is_none")]
426 pub token_id: Option<u32>,
427 #[cfg_attr(feature = "protocol-schema", schema(required))]
428 pub bytes: Option<Vec<u8>>,
429 #[cfg_attr(feature = "protocol-schema", schema(value_type = Vec<crate::schema::TopLogprobs>))]
430 pub top_logprobs: Vec<TopLogprobs>,
431}
432
433#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
434#[serde(rename_all = "lowercase")]
435#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
436#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionToolType))]
437pub enum ChatCompletionToolType {
438 #[default]
439 Function,
440}
441
442#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
443#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
444#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::FunctionName))]
445pub struct FunctionName {
446 pub name: String,
447}
448
449#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
450#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
451#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionNamedToolChoice))]
452pub struct ChatCompletionNamedToolChoice {
453 pub r#type: ChatCompletionToolType,
454 pub function: FunctionName,
455}
456
457fn default_function_type() -> FunctionType {
458 FunctionType::Function
459}
460
461#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
467#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
468#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionMessageToolCall))]
469pub struct ChatCompletionMessageToolCall {
470 pub id: String,
471 #[serde(default = "default_function_type")]
472 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::FunctionType))]
473 pub r#type: FunctionType,
474 pub function: FunctionCall,
475}
476
477#[derive(Clone, Serialize, Default, Debug, Deserialize, PartialEq)]
479#[serde(rename_all = "lowercase")]
480pub enum ChatCompletionToolChoiceOption {
481 #[default]
482 None,
483 Auto,
484 Required,
485 #[serde(untagged)]
486 Named(ChatCompletionNamedToolChoice),
487}
488
489#[cfg(feature = "protocol-schema")]
492impl utoipa::PartialSchema for ChatCompletionToolChoiceOption {
493 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
494 use utoipa::openapi::schema::{AnyOfBuilder, ObjectBuilder, Type};
495 AnyOfBuilder::new()
496 .item(
497 ObjectBuilder::new()
498 .schema_type(Type::String)
499 .enum_values(Some(["none", "auto", "required"])),
500 )
501 .item(ChatCompletionNamedToolChoice::schema())
502 .into()
503 }
504}
505
506#[cfg(feature = "protocol-schema")]
507impl utoipa::ToSchema for ChatCompletionToolChoiceOption {
508 fn name() -> std::borrow::Cow<'static, str> {
509 "dynamo_protocols.chat.ChatCompletionToolChoiceOption".into()
510 }
511
512 fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr<utoipa::openapi::Schema>)>) {
513 ChatCompletionNamedToolChoice::schemas(schemas);
514 }
515}
516
517#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
518#[builder(name = "ChatCompletionToolArgs")]
519#[builder(pattern = "mutable")]
520#[builder(setter(into, strip_option), default)]
521#[builder(derive(Debug))]
522#[builder(build_fn(error = "OpenAIError"))]
523#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
524#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionTool))]
525pub struct ChatCompletionTool {
526 #[builder(default = "ChatCompletionToolType::Function")]
527 pub r#type: ChatCompletionToolType,
528 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::FunctionObject))]
529 pub function: FunctionObject,
530}
531
532#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
543#[serde(untagged)]
544#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
545#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::StopReason))]
546pub enum StopReason {
547 String(String),
548 Int(i64),
549 IntArray(Vec<i64>),
550}
551
552#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
562#[serde(untagged)]
563#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
564#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ReasoningContent))]
565pub enum ReasoningContent {
566 Text(String),
568 Segments(Vec<String>),
571}
572
573impl ReasoningContent {
574 pub fn to_flat_string(&self) -> String {
576 match self {
577 ReasoningContent::Text(s) => s.clone(),
578 ReasoningContent::Segments(segs) => segs
579 .iter()
580 .filter(|s| !s.is_empty())
581 .cloned()
582 .collect::<Vec<_>>()
583 .join("\n"),
584 }
585 }
586
587 pub fn segments(&self) -> Option<&[String]> {
589 match self {
590 ReasoningContent::Segments(segs) => Some(segs),
591 ReasoningContent::Text(_) => None,
592 }
593 }
594}
595
596#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
600#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
601#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartText))]
602pub struct ChatCompletionResponseContentPartText {
603 pub text: String,
604}
605
606#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
608#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
609#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartImageUrl))]
610pub struct ChatCompletionResponseContentPartImageUrl {
611 pub image_url: ImageUrlResponse,
612}
613
614#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
616#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
617#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartVideoUrl))]
618pub struct ChatCompletionResponseContentPartVideoUrl {
619 pub video_url: VideoUrlResponse,
620}
621
622#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
624#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
625#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPartAudioUrl))]
626pub struct ChatCompletionResponseContentPartAudioUrl {
627 pub audio_url: AudioUrlResponse,
628}
629
630#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
631#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
632#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ImageUrlResponse))]
633pub struct ImageUrlResponse {
634 pub url: String,
635 #[serde(skip_serializing_if = "Option::is_none")]
636 pub detail: Option<String>,
637}
638
639#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
640#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
641#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::VideoUrlResponse))]
642pub struct VideoUrlResponse {
643 pub url: String,
644}
645
646#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
647#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
648#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::AudioUrlResponse))]
649pub struct AudioUrlResponse {
650 pub url: String,
651}
652
653#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
655#[serde(tag = "type", rename_all = "snake_case")]
656#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
657#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseContentPart))]
658pub enum ChatCompletionResponseContentPart {
659 Text(ChatCompletionResponseContentPartText),
660 ImageUrl(ChatCompletionResponseContentPartImageUrl),
661 VideoUrl(ChatCompletionResponseContentPartVideoUrl),
662 AudioUrl(ChatCompletionResponseContentPartAudioUrl),
663}
664
665#[derive(Clone, Serialize, Debug, Deserialize, PartialEq)]
671#[serde(untagged)]
672#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
673#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionMessageContent))]
674pub enum ChatCompletionMessageContent {
675 Text(String),
677 Parts(Vec<ChatCompletionResponseContentPart>),
679}
680
681#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
684#[builder(name = "VideoUrlArgs")]
685#[builder(pattern = "mutable")]
686#[builder(setter(into, strip_option))]
687#[builder(derive(Debug))]
688#[builder(build_fn(error = "OpenAIError"))]
689#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
690#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::VideoUrl))]
691pub struct VideoUrl {
692 pub url: Url,
693 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ImageDetail>))]
694 pub detail: Option<ImageDetail>,
695 #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
696 #[serde(skip_serializing_if = "Option::is_none")]
697 pub uuid: Option<Uuid>,
698}
699
700#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
701#[builder(name = "ChatCompletionRequestMessageContentPartVideoArgs")]
702#[builder(pattern = "mutable")]
703#[builder(setter(into, strip_option))]
704#[builder(derive(Debug))]
705#[builder(build_fn(error = "OpenAIError"))]
706#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
707#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessageContentPartVideo))]
708pub struct ChatCompletionRequestMessageContentPartVideo {
709 #[builder(default)]
710 #[serde(default, deserialize_with = "deserialize_optional_media")]
711 pub video_url: Option<VideoUrl>,
712 #[builder(default)]
713 #[serde(skip_serializing_if = "Option::is_none")]
714 pub uuid: Option<String>,
716}
717
718#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
719#[builder(name = "AudioUrlArgs")]
720#[builder(pattern = "mutable")]
721#[builder(setter(into, strip_option))]
722#[builder(derive(Debug))]
723#[builder(build_fn(error = "OpenAIError"))]
724#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
725#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::AudioUrl))]
726pub struct AudioUrl {
727 pub url: Url,
728 #[deprecated(note = "use the content-part `uuid` field for vLLM cache identities")]
729 #[serde(skip_serializing_if = "Option::is_none")]
730 pub uuid: Option<Uuid>,
731}
732
733#[derive(Debug, Serialize, Deserialize, Clone, Builder, PartialEq)]
734#[builder(name = "ChatCompletionRequestMessageContentPartAudioUrlArgs")]
735#[builder(pattern = "mutable")]
736#[builder(setter(into, strip_option))]
737#[builder(derive(Debug))]
738#[builder(build_fn(error = "OpenAIError"))]
739#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
740#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessageContentPartAudioUrl))]
741pub struct ChatCompletionRequestMessageContentPartAudioUrl {
742 #[builder(default)]
743 #[serde(default, deserialize_with = "deserialize_optional_media")]
744 pub audio_url: Option<AudioUrl>,
745 #[builder(default)]
746 #[serde(skip_serializing_if = "Option::is_none")]
747 pub uuid: Option<String>,
749}
750
751#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
755#[serde(untagged)]
756#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
757#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestUserMessageContent))]
758pub enum ChatCompletionRequestUserMessageContent {
759 Text(String),
760 Array(Vec<ChatCompletionRequestUserMessageContentPart>),
761}
762
763#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
764#[builder(name = "ChatCompletionRequestUserMessageArgs")]
765#[builder(pattern = "mutable")]
766#[builder(setter(into, strip_option), default)]
767#[builder(derive(Debug))]
768#[builder(build_fn(error = "OpenAIError"))]
769#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
770#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestUserMessage))]
771pub struct ChatCompletionRequestUserMessage {
772 pub content: ChatCompletionRequestUserMessageContent,
773 #[serde(skip_serializing_if = "Option::is_none")]
774 pub name: Option<String>,
775}
776
777impl Default for ChatCompletionRequestUserMessageContent {
778 fn default() -> Self {
779 Self::Text(String::new())
780 }
781}
782
783impl From<&str> for ChatCompletionRequestUserMessageContent {
784 fn from(value: &str) -> Self {
785 Self::Text(value.into())
786 }
787}
788
789impl From<String> for ChatCompletionRequestUserMessageContent {
790 fn from(value: String) -> Self {
791 Self::Text(value)
792 }
793}
794
795impl From<Vec<ChatCompletionRequestUserMessageContentPart>>
796 for ChatCompletionRequestUserMessageContent
797{
798 fn from(value: Vec<ChatCompletionRequestUserMessageContentPart>) -> Self {
799 Self::Array(value)
800 }
801}
802
803#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
809#[serde(tag = "type")]
810#[serde(rename_all = "snake_case")]
811#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
812#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestUserMessageContentPart))]
813pub enum ChatCompletionRequestUserMessageContentPart {
814 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestMessageContentPartText))]
815 Text(ChatCompletionRequestMessageContentPartText),
816 ImageUrl(ChatCompletionRequestMessageContentPartImage),
817 VideoUrl(ChatCompletionRequestMessageContentPartVideo),
818 AudioUrl(ChatCompletionRequestMessageContentPartAudioUrl),
819 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestMessageContentPartAudio))]
820 InputAudio(ChatCompletionRequestMessageContentPartAudio),
821}
822
823#[derive(Debug, Serialize, Clone, Builder, PartialEq, Default)]
842#[builder(name = "ChatCompletionRequestSystemMessageArgs")]
843#[builder(pattern = "mutable")]
844#[builder(setter(into, strip_option), default)]
845#[builder(derive(Debug))]
846#[builder(build_fn(error = "OpenAIError"))]
847#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
848#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestSystemMessage))]
849pub struct ChatCompletionRequestSystemMessage {
850 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestSystemMessageContent))]
851 pub content: ChatCompletionRequestSystemMessageContent,
852 #[serde(skip_serializing_if = "Option::is_none")]
853 pub name: Option<String>,
854 #[serde(skip_serializing_if = "Option::is_none")]
876 pub tools: Option<Vec<serde_json::Value>>,
877}
878
879impl<'de> Deserialize<'de> for ChatCompletionRequestSystemMessage {
880 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
881 where
882 D: serde::Deserializer<'de>,
883 {
884 use serde::de::Error;
885
886 #[derive(Deserialize)]
890 struct Wire {
891 content: Option<ChatCompletionRequestSystemMessageContent>,
892 name: Option<String>,
893 tools: Option<Vec<serde_json::Value>>,
894 }
895
896 let Wire {
897 content,
898 name,
899 tools,
900 } = Wire::deserialize(deserializer)?;
901 let content = match content {
902 Some(content) => content,
903 None if tools.as_ref().is_some_and(|tools| !tools.is_empty()) => {
904 ChatCompletionRequestSystemMessageContent::Text(String::new())
905 }
906 None => {
907 return Err(D::Error::custom(
908 "missing field `content`: a system message needs `content` unless it \
909 declares non-empty Kimi-style `tools`",
910 ));
911 }
912 };
913 Ok(Self {
914 content,
915 name,
916 tools,
917 })
918 }
919}
920
921#[derive(Debug, Serialize, Deserialize, Default, Clone, Builder, PartialEq)]
929#[builder(name = "ChatCompletionRequestAssistantMessageArgs")]
930#[builder(pattern = "mutable")]
931#[builder(setter(into, strip_option), default)]
932#[builder(derive(Debug))]
933#[builder(build_fn(error = "OpenAIError"))]
934#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
935#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestAssistantMessage))]
936pub struct ChatCompletionRequestAssistantMessage {
937 #[serde(skip_serializing_if = "Option::is_none")]
938 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionRequestAssistantMessageContent>))]
939 pub content: Option<ChatCompletionRequestAssistantMessageContent>,
940 #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
946 pub reasoning_content: Option<ReasoningContent>,
947 #[serde(skip_serializing_if = "Option::is_none")]
948 pub refusal: Option<String>,
949 #[serde(skip_serializing_if = "Option::is_none")]
950 pub name: Option<String>,
951 #[serde(skip_serializing_if = "Option::is_none")]
952 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionRequestAssistantMessageAudio>))]
953 pub audio: Option<ChatCompletionRequestAssistantMessageAudio>,
954 #[serde(skip_serializing_if = "Option::is_none")]
955 pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
956 #[deprecated]
957 #[serde(skip_serializing_if = "Option::is_none")]
958 pub function_call: Option<FunctionCall>,
959 #[serde(skip_serializing_if = "Option::is_none")]
960 pub partial: Option<bool>,
961}
962
963#[derive(Debug, Serialize, Clone, PartialEq)]
974#[serde(tag = "role")]
975#[serde(rename_all = "lowercase")]
976#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
977#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionRequestMessage))]
978pub enum ChatCompletionRequestMessage {
979 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestDeveloperMessage))]
980 Developer(ChatCompletionRequestDeveloperMessage),
981 System(ChatCompletionRequestSystemMessage),
982 User(ChatCompletionRequestUserMessage),
983 Assistant(ChatCompletionRequestAssistantMessage),
984 Tool(ChatCompletionRequestToolMessage),
985 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestFunctionMessage))]
986 Function(ChatCompletionRequestFunctionMessage),
987}
988
989impl<'de> Deserialize<'de> for ChatCompletionRequestMessage {
990 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
991 where
992 D: serde::Deserializer<'de>,
993 {
994 use serde::de::Error;
995
996 #[derive(Deserialize)]
997 struct ForbidToolsAndPartial<T> {
998 tools: Option<serde::de::IgnoredAny>,
999 partial: Option<serde::de::IgnoredAny>,
1000 #[serde(flatten)]
1001 message: T,
1002 }
1003
1004 #[derive(Deserialize)]
1005 struct ForbidTools<T> {
1006 tools: Option<serde::de::IgnoredAny>,
1007 #[serde(flatten)]
1008 message: T,
1009 }
1010
1011 #[derive(Deserialize)]
1012 struct ForbidPartial<T> {
1013 partial: Option<serde::de::IgnoredAny>,
1014 #[serde(flatten)]
1015 message: T,
1016 }
1017
1018 #[derive(Deserialize)]
1019 #[serde(tag = "role")]
1020 #[serde(rename_all = "lowercase")]
1021 enum Wire {
1022 Developer(ForbidToolsAndPartial<ChatCompletionRequestDeveloperMessage>),
1023 System(ForbidPartial<ChatCompletionRequestSystemMessage>),
1024 User(ForbidToolsAndPartial<ChatCompletionRequestUserMessage>),
1025 Assistant(ForbidTools<ChatCompletionRequestAssistantMessage>),
1026 Tool(ForbidToolsAndPartial<ChatCompletionRequestToolMessage>),
1027 Function(ForbidToolsAndPartial<ChatCompletionRequestFunctionMessage>),
1028 }
1029
1030 fn reject_forbidden<E: Error>(
1031 value: Option<serde::de::IgnoredAny>,
1032 field: &str,
1033 allowed_role: &str,
1034 actual_role: &str,
1035 ) -> Result<(), E> {
1036 if value.is_some() {
1037 return Err(E::custom(format!(
1038 "`{field}` is only accepted on {allowed_role} messages, not on role {actual_role}"
1039 )));
1040 }
1041 Ok(())
1042 }
1043
1044 let wire = Wire::deserialize(deserializer)?;
1045 Ok(match wire {
1046 Wire::Developer(ForbidToolsAndPartial {
1047 tools,
1048 partial,
1049 message,
1050 }) => {
1051 reject_forbidden::<D::Error>(tools, "tools", "system", "developer")?;
1052 reject_forbidden::<D::Error>(partial, "partial", "assistant", "developer")?;
1053 ChatCompletionRequestMessage::Developer(message)
1054 }
1055 Wire::System(ForbidPartial { partial, message }) => {
1056 reject_forbidden::<D::Error>(partial, "partial", "assistant", "system")?;
1057 ChatCompletionRequestMessage::System(message)
1058 }
1059 Wire::User(ForbidToolsAndPartial {
1060 tools,
1061 partial,
1062 message,
1063 }) => {
1064 reject_forbidden::<D::Error>(tools, "tools", "system", "user")?;
1065 reject_forbidden::<D::Error>(partial, "partial", "assistant", "user")?;
1066 ChatCompletionRequestMessage::User(message)
1067 }
1068 Wire::Assistant(ForbidTools { tools, message }) => {
1069 reject_forbidden::<D::Error>(tools, "tools", "system", "assistant")?;
1070 ChatCompletionRequestMessage::Assistant(message)
1071 }
1072 Wire::Tool(ForbidToolsAndPartial {
1073 tools,
1074 partial,
1075 message,
1076 }) => {
1077 reject_forbidden::<D::Error>(tools, "tools", "system", "tool")?;
1078 reject_forbidden::<D::Error>(partial, "partial", "assistant", "tool")?;
1079 ChatCompletionRequestMessage::Tool(message)
1080 }
1081 Wire::Function(ForbidToolsAndPartial {
1082 tools,
1083 partial,
1084 message,
1085 }) => {
1086 reject_forbidden::<D::Error>(tools, "tools", "system", "function")?;
1087 reject_forbidden::<D::Error>(partial, "partial", "assistant", "function")?;
1088 ChatCompletionRequestMessage::Function(message)
1089 }
1090 })
1091 }
1092}
1093
1094pub type ServiceTierResponse = ServiceTier;
1096
1097#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1103#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1104#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionResponseMessage))]
1105pub struct ChatCompletionResponseMessage {
1106 #[cfg_attr(feature = "protocol-schema", schema(required))]
1110 pub content: Option<ChatCompletionMessageContent>,
1111 #[cfg_attr(feature = "protocol-schema", schema(required))]
1115 pub refusal: Option<String>,
1116 #[serde(skip_serializing_if = "Option::is_none")]
1117 pub tool_calls: Option<Vec<ChatCompletionMessageToolCall>>,
1118 #[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::Role))]
1119 pub role: Role,
1120 #[serde(skip_serializing_if = "Option::is_none")]
1121 #[deprecated]
1122 pub function_call: Option<FunctionCall>,
1123 #[serde(skip_serializing_if = "Option::is_none")]
1124 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionResponseMessageAudio>))]
1125 pub audio: Option<ChatCompletionResponseMessageAudio>,
1126 #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
1133 pub reasoning_content: Option<String>,
1134}
1135
1136fn deserialize_null_as_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
1137where
1138 D: serde::Deserializer<'de>,
1139{
1140 Option::<bool>::deserialize(deserializer).map(Option::unwrap_or_default)
1141}
1142
1143#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
1148#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1149#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionStreamOptions))]
1150pub struct ChatCompletionStreamOptions {
1151 #[serde(default, deserialize_with = "deserialize_null_as_false")]
1152 pub include_usage: bool,
1153 #[serde(default, deserialize_with = "deserialize_null_as_false")]
1156 pub continuous_usage_stats: bool,
1157}
1158
1159#[derive(Clone, Serialize, Default, Debug, Builder, Deserialize, PartialEq)]
1166#[builder(name = "CreateChatCompletionRequestArgs")]
1167#[builder(pattern = "mutable")]
1168#[builder(setter(into, strip_option), default)]
1169#[builder(derive(Debug))]
1170#[builder(build_fn(error = "OpenAIError"))]
1171#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1172#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::CreateChatCompletionRequest))]
1173pub struct CreateChatCompletionRequest {
1174 pub messages: Vec<ChatCompletionRequestMessage>,
1175 pub model: String,
1176 #[serde(skip_serializing_if = "Option::is_none")]
1178 pub mm_processor_kwargs: Option<serde_json::Value>,
1179 #[serde(skip_serializing_if = "Option::is_none")]
1180 pub store: Option<bool>,
1181 #[serde(skip_serializing_if = "Option::is_none")]
1182 pub reasoning_effort: Option<ReasoningEffort>,
1183 #[serde(skip_serializing_if = "Option::is_none")]
1184 pub metadata: Option<serde_json::Value>,
1185 #[serde(skip_serializing_if = "Option::is_none")]
1186 pub frequency_penalty: Option<f32>,
1187 #[serde(skip_serializing_if = "Option::is_none")]
1188 pub logit_bias: Option<std::collections::HashMap<String, serde_json::Value>>,
1189 #[serde(skip_serializing_if = "Option::is_none")]
1190 pub logprobs: Option<bool>,
1191 #[serde(skip_serializing_if = "Option::is_none")]
1192 pub top_logprobs: Option<u8>,
1193 #[deprecated]
1194 #[serde(skip_serializing_if = "Option::is_none")]
1195 pub max_tokens: Option<u32>,
1196 #[serde(skip_serializing_if = "Option::is_none")]
1197 pub max_completion_tokens: Option<u32>,
1198 #[serde(skip_serializing_if = "Option::is_none")]
1199 pub n: Option<u8>,
1200 #[serde(skip_serializing_if = "Option::is_none")]
1201 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<Vec<crate::schema::ResponseModalities>>))]
1202 pub modalities: Option<Vec<async_openai::types::chat::ResponseModalities>>,
1203 #[serde(skip_serializing_if = "Option::is_none")]
1204 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::PredictionContent>))]
1205 pub prediction: Option<PredictionContent>,
1206 #[serde(skip_serializing_if = "Option::is_none")]
1207 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionAudio>))]
1208 pub audio: Option<ChatCompletionAudio>,
1209 #[serde(skip_serializing_if = "Option::is_none")]
1210 pub presence_penalty: Option<f32>,
1211 #[serde(skip_serializing_if = "Option::is_none")]
1212 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ResponseFormat>))]
1213 pub response_format: Option<ResponseFormat>,
1214 #[serde(skip_serializing_if = "Option::is_none")]
1215 pub seed: Option<i64>,
1216 #[serde(skip_serializing_if = "Option::is_none")]
1217 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ServiceTier>))]
1218 pub service_tier: Option<ServiceTier>,
1219 #[serde(skip_serializing_if = "Option::is_none")]
1220 pub stop: Option<Stop>,
1221 #[serde(default, skip_serializing_if = "Option::is_none")]
1222 pub stream: Option<bool>,
1223 #[serde(skip_serializing_if = "Option::is_none")]
1224 pub stream_options: Option<ChatCompletionStreamOptions>,
1225 #[serde(skip_serializing_if = "Option::is_none")]
1226 pub temperature: Option<f32>,
1227 #[serde(skip_serializing_if = "Option::is_none")]
1228 pub top_p: Option<f32>,
1229 #[serde(skip_serializing_if = "Option::is_none")]
1230 pub tools: Option<Vec<ChatCompletionTool>>,
1231 #[serde(skip_serializing_if = "Option::is_none")]
1232 pub tool_choice: Option<ChatCompletionToolChoiceOption>,
1233 #[serde(skip_serializing_if = "Option::is_none")]
1234 pub parallel_tool_calls: Option<bool>,
1235 #[serde(skip_serializing_if = "Option::is_none")]
1236 pub user: Option<String>,
1237 #[serde(skip_serializing_if = "Option::is_none")]
1246 pub prompt_cache_key: Option<String>,
1247 #[deprecated]
1248 #[serde(skip_serializing_if = "Option::is_none")]
1249 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ChatCompletionFunctionCall>))]
1250 pub function_call: Option<ChatCompletionFunctionCall>,
1251 #[deprecated]
1252 #[serde(skip_serializing_if = "Option::is_none")]
1253 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<Vec<crate::schema::ChatCompletionFunctions>>))]
1254 pub functions: Option<Vec<ChatCompletionFunctions>>,
1255 #[serde(skip_serializing_if = "Option::is_none")]
1256 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::WebSearchOptions>))]
1257 pub web_search_options: Option<WebSearchOptions>,
1258}
1259
1260impl CreateChatCompletionRequest {
1261 pub fn dynamic_system_tools(&self) -> impl Iterator<Item = &serde_json::Value> {
1272 self.messages
1273 .iter()
1274 .filter_map(|message| match message {
1275 ChatCompletionRequestMessage::System(system) => system.tools.as_deref(),
1276 _ => None,
1277 })
1278 .flatten()
1279 }
1280
1281 pub fn has_effective_tools(&self) -> bool {
1288 self.tools.as_ref().is_some_and(|tools| !tools.is_empty())
1289 || self.dynamic_system_tools().next().is_some()
1290 }
1291
1292 pub fn effective_tool_names(&self) -> impl Iterator<Item = &str> {
1295 self.tools
1296 .iter()
1297 .flatten()
1298 .map(|tool| tool.function.name.as_str())
1299 .chain(self.dynamic_system_tools().filter_map(dynamic_tool_name))
1300 }
1301
1302 pub fn effective_tool_contains(&self, name: &str) -> bool {
1307 self.effective_tool_names().any(|tool| tool == name)
1308 }
1309}
1310
1311pub fn dynamic_tool_name(tool: &serde_json::Value) -> Option<&str> {
1318 tool.get("function")
1319 .and_then(serde_json::Value::as_object)
1320 .and_then(|function| function.get("name"))
1321 .or_else(|| tool.get("name"))
1322 .and_then(serde_json::Value::as_str)
1323}
1324
1325#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1329#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1330#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatChoice))]
1331pub struct ChatChoice {
1332 pub index: u32,
1333 pub message: ChatCompletionResponseMessage,
1334 #[cfg_attr(feature = "protocol-schema", schema(required, value_type = Option<crate::schema::FinishReason>))]
1336 pub finish_reason: Option<FinishReason>,
1337 #[cfg_attr(feature = "protocol-schema", schema(required))]
1338 pub logprobs: Option<ChatChoiceLogprobs>,
1339}
1340
1341fn serialize_usage_omitting_absent<S>(
1351 usage: &Option<CompletionUsage>,
1352 serializer: S,
1353) -> Result<S::Ok, S::Error>
1354where
1355 S: serde::Serializer,
1356{
1357 #[derive(Serialize)]
1358 struct PromptDetailsShadow {
1359 #[serde(skip_serializing_if = "Option::is_none")]
1360 audio_tokens: Option<u32>,
1361 #[serde(skip_serializing_if = "Option::is_none")]
1362 cached_tokens: Option<u32>,
1363 }
1364
1365 #[derive(Serialize)]
1366 struct CompletionDetailsShadow {
1367 #[serde(skip_serializing_if = "Option::is_none")]
1368 accepted_prediction_tokens: Option<u32>,
1369 #[serde(skip_serializing_if = "Option::is_none")]
1370 audio_tokens: Option<u32>,
1371 #[serde(skip_serializing_if = "Option::is_none")]
1372 reasoning_tokens: Option<u32>,
1373 #[serde(skip_serializing_if = "Option::is_none")]
1374 rejected_prediction_tokens: Option<u32>,
1375 }
1376
1377 #[derive(Serialize)]
1378 struct UsageShadow {
1379 prompt_tokens: u32,
1380 completion_tokens: u32,
1381 total_tokens: u32,
1382 #[serde(skip_serializing_if = "Option::is_none")]
1383 prompt_tokens_details: Option<PromptDetailsShadow>,
1384 #[serde(skip_serializing_if = "Option::is_none")]
1385 completion_tokens_details: Option<CompletionDetailsShadow>,
1386 }
1387
1388 match usage {
1389 None => serializer.serialize_none(),
1390 Some(u) => UsageShadow {
1391 prompt_tokens: u.prompt_tokens,
1392 completion_tokens: u.completion_tokens,
1393 total_tokens: u.total_tokens,
1394 prompt_tokens_details: u
1395 .prompt_tokens_details
1396 .as_ref()
1397 .map(|d| PromptDetailsShadow {
1398 audio_tokens: d.audio_tokens,
1399 cached_tokens: d.cached_tokens,
1400 }),
1401 completion_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
1402 CompletionDetailsShadow {
1403 accepted_prediction_tokens: d.accepted_prediction_tokens,
1404 audio_tokens: d.audio_tokens,
1405 reasoning_tokens: d.reasoning_tokens,
1406 rejected_prediction_tokens: d.rejected_prediction_tokens,
1407 }
1408 }),
1409 }
1410 .serialize(serializer),
1411 }
1412}
1413
1414#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1421#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1422#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::CreateChatCompletionResponse))]
1423pub struct CreateChatCompletionResponse {
1424 pub id: String,
1425 pub choices: Vec<ChatChoice>,
1426 pub created: u32,
1427 pub model: String,
1428 #[serde(skip_serializing_if = "Option::is_none")]
1429 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ServiceTier>))]
1430 pub service_tier: Option<ServiceTierResponse>,
1431 #[serde(skip_serializing_if = "Option::is_none")]
1432 pub system_fingerprint: Option<String>,
1433 pub object: String,
1434 #[serde(
1435 skip_serializing_if = "Option::is_none",
1436 serialize_with = "serialize_usage_omitting_absent"
1437 )]
1438 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::CompletionUsage>))]
1439 pub usage: Option<CompletionUsage>,
1440}
1441
1442pub type ChatCompletionResponseStream =
1443 Pin<Box<dyn Stream<Item = Result<CreateChatCompletionStreamResponse, OpenAIError>> + Send>>;
1444
1445#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1451#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1452#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionStreamResponseDelta))]
1453pub struct ChatCompletionStreamResponseDelta {
1454 #[serde(skip_serializing_if = "Option::is_none")]
1455 pub content: Option<ChatCompletionMessageContent>,
1456 #[serde(skip_serializing_if = "Option::is_none")]
1457 pub function_call: Option<ChatCompletionStreamResponseDeltaFunctionCall>,
1458 #[serde(skip_serializing_if = "Option::is_none")]
1459 pub tool_calls: Option<Vec<ChatCompletionMessageToolCallChunk>>,
1460 #[serde(skip_serializing_if = "Option::is_none")]
1461 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::Role>))]
1462 pub role: Option<Role>,
1463 #[serde(skip_serializing_if = "Option::is_none")]
1464 pub refusal: Option<String>,
1465 #[serde(default, alias = "reasoning", skip_serializing_if = "Option::is_none")]
1471 pub reasoning_content: Option<String>,
1472}
1473
1474#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
1475#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1476#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatCompletionStreamResponseDeltaFunctionCall))]
1477pub struct ChatCompletionStreamResponseDeltaFunctionCall {
1478 #[serde(skip_serializing_if = "Option::is_none")]
1479 pub name: Option<String>,
1480 #[serde(
1481 default,
1482 deserialize_with = "deserialize_arguments_opt",
1483 skip_serializing_if = "Option::is_none"
1484 )]
1485 pub arguments: Option<String>,
1486}
1487
1488#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1490#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1491#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::ChatChoiceStream))]
1492pub struct ChatChoiceStream {
1493 pub index: u32,
1494 pub delta: ChatCompletionStreamResponseDelta,
1495 #[cfg_attr(feature = "protocol-schema", schema(required, value_type = Option<crate::schema::FinishReason>))]
1497 pub finish_reason: Option<FinishReason>,
1498 #[cfg_attr(feature = "protocol-schema", schema(required))]
1499 pub logprobs: Option<ChatChoiceLogprobs>,
1500}
1501
1502#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)]
1510#[cfg_attr(feature = "protocol-schema", derive(utoipa::ToSchema))]
1511#[cfg_attr(feature = "protocol-schema", schema(as = dynamo_protocols::chat::CreateChatCompletionStreamResponse))]
1512pub struct CreateChatCompletionStreamResponse {
1513 pub id: String,
1514 pub choices: Vec<ChatChoiceStream>,
1515 pub created: u32,
1516 pub model: String,
1517 #[serde(skip_serializing_if = "Option::is_none")]
1518 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::ServiceTier>))]
1519 pub service_tier: Option<ServiceTierResponse>,
1520 #[serde(skip_serializing_if = "Option::is_none")]
1521 pub system_fingerprint: Option<String>,
1522 pub object: String,
1523 #[serde(
1524 skip_serializing_if = "Option::is_none",
1525 serialize_with = "serialize_usage_omitting_absent"
1526 )]
1527 #[cfg_attr(feature = "protocol-schema", schema(value_type = Option<crate::schema::CompletionUsage>))]
1528 pub usage: Option<CompletionUsage>,
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533 use super::*;
1534
1535 #[test]
1536 fn stream_options_default_missing_and_null_flags_to_false() {
1537 for (payload, expected) in [
1538 (serde_json::json!({}), (false, false)),
1539 (
1540 serde_json::json!({
1541 "include_usage": null,
1542 "continuous_usage_stats": true,
1543 }),
1544 (false, true),
1545 ),
1546 (
1547 serde_json::json!({
1548 "include_usage": true,
1549 "continuous_usage_stats": null,
1550 }),
1551 (true, false),
1552 ),
1553 ] {
1554 let options: ChatCompletionStreamOptions = serde_json::from_value(payload).unwrap();
1555 assert_eq!(
1556 (options.include_usage, options.continuous_usage_stats),
1557 expected
1558 );
1559 }
1560 }
1561
1562 #[test]
1563 fn stream_options_preserve_boolean_wire_shape_and_reject_other_types() {
1564 let options: ChatCompletionStreamOptions = serde_json::from_value(serde_json::json!({
1565 "include_usage": true,
1566 "continuous_usage_stats": false,
1567 }))
1568 .unwrap();
1569 assert!(options.include_usage);
1570 assert!(!options.continuous_usage_stats);
1571 assert_eq!(
1572 serde_json::to_value(options).unwrap(),
1573 serde_json::json!({
1574 "include_usage": true,
1575 "continuous_usage_stats": false,
1576 })
1577 );
1578
1579 for payload in [
1580 serde_json::json!({"include_usage": "true"}),
1581 serde_json::json!({"continuous_usage_stats": 1}),
1582 ] {
1583 serde_json::from_value::<ChatCompletionStreamOptions>(payload).unwrap_err();
1584 }
1585 }
1586
1587 #[test]
1588 fn stop_accepts_token_id_array() {
1589 let stop: Stop = serde_json::from_value(serde_json::json!([32, 34])).unwrap();
1590
1591 assert_eq!(stop, Stop::TokenIdArray(vec![32, 34]));
1592 }
1593
1594 #[test]
1595 fn stop_accepts_string_and_string_array() {
1596 let stop: Stop = serde_json::from_value(serde_json::json!(" The")).unwrap();
1597
1598 assert_eq!(stop, Stop::String(" The".to_string()));
1599
1600 let stop: Stop = serde_json::from_value(serde_json::json!(["A", "B"])).unwrap();
1601
1602 assert_eq!(
1603 stop,
1604 Stop::StringArray(vec!["A".to_string(), "B".to_string()])
1605 );
1606 }
1607
1608 #[test]
1609 fn stop_token_id_display_string_remains_string_stop() {
1610 let stop: Stop = serde_json::from_value(serde_json::json!("token_id:576")).unwrap();
1611
1612 assert_eq!(stop, Stop::String("token_id:576".to_string()));
1613
1614 let stop: Stop = serde_json::from_value(serde_json::json!(["token_id:576"])).unwrap();
1615
1616 assert_eq!(stop, Stop::StringArray(vec!["token_id:576".to_string()]));
1617 }
1618
1619 #[test]
1620 fn stop_rejects_single_token_id() {
1621 let result = serde_json::from_value::<Stop>(serde_json::json!(576));
1622
1623 assert!(result.is_err());
1624 }
1625
1626 #[test]
1627 fn stop_converts_from_upstream_stop_configuration() {
1628 let upstream =
1629 async_openai::types::chat::StopConfiguration::StringArray(vec!["END".to_string()]);
1630
1631 assert_eq!(
1632 Stop::from(upstream),
1633 Stop::StringArray(vec!["END".to_string()])
1634 );
1635 }
1636
1637 #[test]
1638 fn request_builder_accepts_upstream_reasoning_effort() {
1639 let request = CreateChatCompletionRequestArgs::default()
1640 .reasoning_effort(async_openai::types::chat::ReasoningEffort::High)
1641 .build()
1642 .unwrap();
1643
1644 assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High));
1645 }
1646
1647 #[test]
1648 fn tool_call_defaults_type_on_deserialize() {
1649 let tool_call: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1650 "id": "call_123",
1651 "function": {
1652 "name": "get_weather",
1653 "arguments": "{\"location\":\"SF\"}"
1654 }
1655 }))
1656 .unwrap();
1657
1658 assert_eq!(tool_call.r#type, FunctionType::Function);
1659 }
1660
1661 #[test]
1662 fn tool_call_serializes_type_for_wire_compat() {
1663 let tool_call = ChatCompletionMessageToolCall {
1664 id: "call_123".into(),
1665 r#type: FunctionType::Function,
1666 function: FunctionCall {
1667 name: "get_weather".into(),
1668 arguments: "{\"location\":\"SF\"}".into(),
1669 },
1670 };
1671
1672 let json = serde_json::to_value(tool_call).unwrap();
1673 assert_eq!(json["type"], "function");
1674 }
1675
1676 #[test]
1679 fn function_call_accepts_string_arguments() {
1680 let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1681 "name": "get_weather",
1682 "arguments": "{\"location\":\"SF\"}"
1683 }))
1684 .unwrap();
1685 assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1686 }
1687
1688 #[test]
1689 fn function_call_accepts_dict_arguments() {
1690 let fc: FunctionCall = serde_json::from_value(serde_json::json!({
1691 "name": "get_weather",
1692 "arguments": {"location": "SF"}
1693 }))
1694 .unwrap();
1695 assert_eq!(fc.arguments, "{\"location\":\"SF\"}");
1696 }
1697
1698 #[test]
1699 fn function_call_rejects_integer_arguments() {
1700 let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1701 "name": "f",
1702 "arguments": 42
1703 }));
1704 assert!(result.is_err());
1705 }
1706
1707 #[test]
1708 fn function_call_rejects_boolean_arguments() {
1709 let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1710 "name": "f",
1711 "arguments": true
1712 }));
1713 assert!(result.is_err());
1714 }
1715
1716 #[test]
1717 fn function_call_rejects_null_arguments() {
1718 let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1719 "name": "f",
1720 "arguments": null
1721 }));
1722 assert!(result.is_err());
1723 }
1724
1725 #[test]
1726 fn function_call_rejects_array_arguments() {
1727 let result = serde_json::from_value::<FunctionCall>(serde_json::json!({
1728 "name": "f",
1729 "arguments": [1, 2, 3]
1730 }));
1731 assert!(result.is_err());
1732 }
1733
1734 #[test]
1735 fn function_call_stream_null_arguments_produces_none() {
1736 let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1737 "name": "f",
1738 "arguments": null
1739 }))
1740 .unwrap();
1741 assert_eq!(fcs.arguments, None);
1742 }
1743
1744 #[test]
1745 fn function_call_stream_rejects_integer_arguments() {
1746 let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1747 "name": "f",
1748 "arguments": 42
1749 }));
1750 assert!(result.is_err());
1751 }
1752
1753 #[test]
1754 fn function_call_stream_rejects_boolean_arguments() {
1755 let result = serde_json::from_value::<FunctionCallStream>(serde_json::json!({
1756 "name": "f",
1757 "arguments": true
1758 }));
1759 assert!(result.is_err());
1760 }
1761
1762 #[test]
1763 fn function_call_stream_accepts_dict_arguments() {
1764 let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1765 "name": "get_weather",
1766 "arguments": {"location": "SF"}
1767 }))
1768 .unwrap();
1769 assert_eq!(fcs.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1770 }
1771
1772 #[test]
1773 fn function_call_stream_accepts_null_arguments() {
1774 let fcs: FunctionCallStream = serde_json::from_value(serde_json::json!({
1775 "name": "get_weather"
1776 }))
1777 .unwrap();
1778 assert_eq!(fcs.arguments, None);
1779 }
1780
1781 #[test]
1782 fn tool_call_with_dict_arguments_roundtrip() {
1783 let tc: ChatCompletionMessageToolCall = serde_json::from_value(serde_json::json!({
1784 "id": "call_abc",
1785 "type": "function",
1786 "function": {
1787 "name": "search",
1788 "arguments": {"query": "hello", "limit": 10}
1789 }
1790 }))
1791 .unwrap();
1792 let parsed: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap();
1794 assert_eq!(parsed, serde_json::json!({"query": "hello", "limit": 10}));
1795 let json = serde_json::to_value(&tc).unwrap();
1797 assert!(json["function"]["arguments"].is_string());
1798 }
1799
1800 #[test]
1801 fn stream_delta_function_call_accepts_dict_arguments() {
1802 let delta: ChatCompletionStreamResponseDeltaFunctionCall =
1803 serde_json::from_value(serde_json::json!({
1804 "name": "get_weather",
1805 "arguments": {"location": "SF"}
1806 }))
1807 .unwrap();
1808 assert_eq!(delta.arguments.as_deref(), Some("{\"location\":\"SF\"}"));
1809 }
1810
1811 fn parse_content_part(json: serde_json::Value) -> ChatCompletionRequestUserMessageContentPart {
1812 serde_json::from_value(json).expect("content part deserialization failed")
1813 }
1814
1815 #[test]
1816 fn image_url_url_and_top_level_uuid() {
1817 let part = parse_content_part(serde_json::json!({
1818 "type": "image_url",
1819 "image_url": {"url": "https://x.example/y.png"},
1820 "uuid": "image-123"
1821 }));
1822
1823 match part {
1824 ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1825 assert_eq!(part.uuid.as_deref(), Some("image-123"));
1826 assert_eq!(
1827 part.image_url.as_ref().map(|image| image.url.as_str()),
1828 Some("https://x.example/y.png")
1829 );
1830 }
1831 _ => panic!("expected image_url part"),
1832 }
1833 }
1834
1835 #[test]
1836 fn image_url_null_and_top_level_uuid() {
1837 let part = parse_content_part(serde_json::json!({
1838 "type": "image_url",
1839 "image_url": null,
1840 "uuid": "sku-1234-a"
1841 }));
1842
1843 match part {
1844 ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1845 assert!(part.image_url.is_none());
1846 assert_eq!(part.uuid.as_deref(), Some("sku-1234-a"));
1847 }
1848 _ => panic!("expected image_url part"),
1849 }
1850 }
1851
1852 #[test]
1853 fn empty_media_urls_deserialize_as_uuid_only() {
1854 for (part_type, media_field, uuid) in [
1855 ("image_url", "image_url", "image-cache-key"),
1856 ("video_url", "video_url", "video-cache-key"),
1857 ("audio_url", "audio_url", "audio-cache-key"),
1858 ] {
1859 let part = parse_content_part(serde_json::json!({
1860 "type": part_type,
1861 (media_field): {"url": ""},
1862 "uuid": uuid
1863 }));
1864 let json = serde_json::to_value(part).unwrap();
1865
1866 assert!(json[media_field].is_null());
1867 assert_eq!(json["uuid"], uuid);
1868 }
1869 }
1870
1871 #[test]
1872 fn image_url_null_without_uuid_deserializes_for_use_site_validation() {
1873 let part = parse_content_part(serde_json::json!({
1874 "type": "image_url",
1875 "image_url": null
1876 }));
1877
1878 match part {
1879 ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1880 assert!(part.image_url.is_none());
1881 assert!(part.uuid.is_none());
1882 }
1883 _ => panic!("expected image_url part"),
1884 }
1885 }
1886
1887 #[test]
1888 fn image_url_serialize_uuid_only_uses_null_image_url() {
1889 let part = ChatCompletionRequestMessageContentPartImage {
1890 image_url: None,
1891 uuid: Some("image-123".to_string()),
1892 };
1893 let json = serde_json::to_value(part).unwrap();
1894
1895 assert!(json["image_url"].is_null());
1896 assert_eq!(json["uuid"], "image-123");
1897 }
1898
1899 #[test]
1900 fn cached_media_builders_allow_omitting_urls() {
1901 let image = ChatCompletionRequestMessageContentPartImageArgs::default()
1902 .uuid("image-123")
1903 .build()
1904 .unwrap();
1905 let video = ChatCompletionRequestMessageContentPartVideoArgs::default()
1906 .uuid("video-123")
1907 .build()
1908 .unwrap();
1909 let audio = ChatCompletionRequestMessageContentPartAudioUrlArgs::default()
1910 .uuid("audio-123")
1911 .build()
1912 .unwrap();
1913
1914 let image_json = serde_json::to_value(image).unwrap();
1915 let video_json = serde_json::to_value(video).unwrap();
1916 let audio_json = serde_json::to_value(audio).unwrap();
1917 assert!(image_json["image_url"].is_null());
1918 assert!(video_json["video_url"].is_null());
1919 assert!(audio_json["audio_url"].is_null());
1920 }
1921
1922 #[test]
1923 fn image_url_uuid_accepts_opaque_string() {
1924 let part = parse_content_part(serde_json::json!({
1925 "type": "image_url",
1926 "image_url": {"url": "https://x.example/y.png"},
1927 "uuid": "img-ac3921de680bb217"
1928 }));
1929
1930 match part {
1931 ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
1932 assert_eq!(part.uuid.as_deref(), Some("img-ac3921de680bb217"));
1933 }
1934 _ => panic!("expected image_url part"),
1935 }
1936 }
1937
1938 #[test]
1939 fn url_conversions_preserve_required_urls() {
1940 let image: ImageUrl = "https://x.example/image.png".into();
1941 let video: VideoUrl = "https://x.example/video.mp4".into();
1942 let audio: AudioUrl = "https://x.example/audio.wav".into();
1943
1944 assert_eq!(image.url.as_str(), "https://x.example/image.png");
1945 assert_eq!(video.url.as_str(), "https://x.example/video.mp4");
1946 assert_eq!(audio.url.as_str(), "https://x.example/audio.wav");
1947 }
1948
1949 #[test]
1950 fn invalid_media_urls_remain_rejected() {
1951 for (part_type, media_field) in [
1952 ("image_url", "image_url"),
1953 ("video_url", "video_url"),
1954 ("audio_url", "audio_url"),
1955 ] {
1956 let result = serde_json::from_value::<ChatCompletionRequestUserMessageContentPart>(
1957 serde_json::json!({
1958 "type": part_type,
1959 (media_field): {"url": "not a url"},
1960 "uuid": "cache-key"
1961 }),
1962 );
1963
1964 assert!(result.is_err(), "{part_type} accepted an invalid URL");
1965 }
1966 }
1967
1968 #[test]
1969 fn legacy_nested_media_uuids_remain_accepted() {
1970 let legacy_uuid = "92b888ad-e64a-478f-b688-5091e16544e3";
1971
1972 for (part_type, media_field, url) in [
1973 ("image_url", "image_url", "https://x.example/image.png"),
1974 ("video_url", "video_url", "https://x.example/video.mp4"),
1975 ("audio_url", "audio_url", "https://x.example/audio.wav"),
1976 ] {
1977 let part = parse_content_part(serde_json::json!({
1978 "type": part_type,
1979 (media_field): {"url": url, "uuid": legacy_uuid}
1980 }));
1981 let json = serde_json::to_value(part).unwrap();
1982
1983 assert_eq!(json[media_field]["url"], url);
1984 assert_eq!(json[media_field]["uuid"], legacy_uuid);
1985 assert!(json.get("uuid").is_none());
1986 }
1987 }
1988
1989 #[test]
1990 fn video_url_null_and_top_level_uuid() {
1991 let part = parse_content_part(serde_json::json!({
1992 "type": "video_url",
1993 "video_url": null,
1994 "uuid": "video-cache-key"
1995 }));
1996
1997 match part {
1998 ChatCompletionRequestUserMessageContentPart::VideoUrl(part) => {
1999 assert!(part.video_url.is_none());
2000 assert_eq!(part.uuid.as_deref(), Some("video-cache-key"));
2001 }
2002 _ => panic!("expected video_url part"),
2003 }
2004 }
2005
2006 #[test]
2007 fn audio_url_null_and_top_level_uuid() {
2008 let part = parse_content_part(serde_json::json!({
2009 "type": "audio_url",
2010 "audio_url": null,
2011 "uuid": "audio-cache-key"
2012 }));
2013
2014 match part {
2015 ChatCompletionRequestUserMessageContentPart::AudioUrl(part) => {
2016 assert!(part.audio_url.is_none());
2017 assert_eq!(part.uuid.as_deref(), Some("audio-cache-key"));
2018 }
2019 _ => panic!("expected audio_url part"),
2020 }
2021 }
2022
2023 #[test]
2024 fn message_content_array_preserves_uuid_alignment() {
2025 let payload = serde_json::json!({
2026 "role": "user",
2027 "content": [
2028 {"type": "text", "text": "describe these"},
2029 {
2030 "type": "image_url",
2031 "image_url": {"url": "https://x.example/img1.png"},
2032 "uuid": "image-1"
2033 },
2034 {"type": "image_url", "image_url": null, "uuid": "image-1"}
2035 ]
2036 });
2037 let message: ChatCompletionRequestUserMessage = serde_json::from_value(payload).unwrap();
2038 let ChatCompletionRequestUserMessageContent::Array(parts) = message.content else {
2039 panic!("expected content array");
2040 };
2041
2042 assert_eq!(parts.len(), 3);
2043 match &parts[1] {
2044 ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
2045 assert!(
2046 part.image_url
2047 .as_ref()
2048 .map(|image| image.url.as_str())
2049 .is_some()
2050 );
2051 assert_eq!(part.uuid.as_deref(), Some("image-1"));
2052 }
2053 _ => panic!("parts[1] should be image_url"),
2054 }
2055 match &parts[2] {
2056 ChatCompletionRequestUserMessageContentPart::ImageUrl(part) => {
2057 assert!(part.image_url.is_none());
2058 assert_eq!(part.uuid.as_deref(), Some("image-1"));
2059 }
2060 _ => panic!("parts[2] should be image_url"),
2061 }
2062 }
2063
2064 #[test]
2065 fn tool_message_accepts_media_content() {
2066 let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
2067 "role": "tool",
2068 "tool_call_id": "call_media",
2069 "content": [
2070 {"type": "text", "text": "Screenshot captured"},
2071 {
2072 "type": "image_url",
2073 "image_url": {
2074 "url": "data:image/png;base64,aGVsbG8="
2075 }
2076 },
2077 {
2078 "type": "video_url",
2079 "video_url": {
2080 "url": "https://example.com/clip.mp4"
2081 }
2082 },
2083 {
2084 "type": "audio_url",
2085 "audio_url": {
2086 "url": "https://example.com/audio.wav"
2087 }
2088 }
2089 ]
2090 }))
2091 .unwrap();
2092
2093 let ChatCompletionRequestMessage::Tool(tool) = message else {
2094 panic!("expected tool message");
2095 };
2096 let ChatCompletionRequestToolMessageContent::Array(parts) = tool.content else {
2097 panic!("expected array content");
2098 };
2099 assert!(matches!(
2100 parts[1],
2101 ChatCompletionRequestToolMessageContentPart::ImageUrl(_)
2102 ));
2103 assert!(matches!(
2104 parts[2],
2105 ChatCompletionRequestToolMessageContentPart::VideoUrl(_)
2106 ));
2107 assert!(matches!(
2108 parts[3],
2109 ChatCompletionRequestToolMessageContentPart::AudioUrl(_)
2110 ));
2111 }
2112
2113 #[test]
2114 fn chat_logprob_serializes_token_id_when_present() {
2115 let logprob = ChatCompletionTokenLogprob {
2116 token: " hello".into(),
2117 logprob: -0.12,
2118 token_id: Some(123),
2119 bytes: Some(vec![32, 104, 101, 108, 108, 111]),
2120 top_logprobs: vec![],
2121 };
2122
2123 let json = serde_json::to_value(logprob).unwrap();
2124
2125 assert_eq!(json["token_id"], 123);
2126 }
2127
2128 #[test]
2129 fn chat_logprob_deserializes_optional_fields() {
2130 let choice_logprobs: ChatChoiceLogprobs = serde_json::from_value(serde_json::json!({
2131 "content": [{
2132 "token": " hello",
2133 "logprob": -0.12,
2134 "top_logprobs": []
2135 }]
2136 }))
2137 .unwrap();
2138 let token_logprob: ChatCompletionTokenLogprob = serde_json::from_value(serde_json::json!({
2139 "token": " hello",
2140 "logprob": -0.12,
2141 "token_id": 123,
2142 "bytes": [32, 104, 101, 108, 108, 111],
2143 "top_logprobs": []
2144 }))
2145 .unwrap();
2146
2147 assert_eq!(choice_logprobs.content.as_ref().unwrap()[0].token_id, None);
2148 assert!(choice_logprobs.refusal.is_none());
2149 assert_eq!(token_logprob.token_id, Some(123));
2150 assert_eq!(token_logprob.bytes, Some(vec![32, 104, 101, 108, 108, 111]));
2151 }
2152
2153 #[test]
2154 fn chat_logprob_preserves_nullable_fields() {
2155 let choice_logprobs = ChatChoiceLogprobs {
2156 content: None,
2157 refusal: None,
2158 };
2159 let token_logprob = ChatCompletionTokenLogprob {
2160 token: " hello".into(),
2161 logprob: -0.12,
2162 token_id: None,
2163 bytes: None,
2164 top_logprobs: vec![],
2165 };
2166
2167 let choice_json = serde_json::to_value(choice_logprobs).unwrap();
2168 let token_json = serde_json::to_value(token_logprob).unwrap();
2169
2170 assert_eq!(choice_json["content"], serde_json::Value::Null);
2171 assert_eq!(choice_json["refusal"], serde_json::Value::Null);
2172 assert!(token_json.get("token_id").is_none());
2173 assert_eq!(token_json["bytes"], serde_json::Value::Null);
2174 }
2175
2176 #[test]
2177 #[allow(deprecated)]
2178 fn chat_response_omits_absent_optional_fields() {
2179 let response = CreateChatCompletionResponse {
2180 id: "chatcmpl_dummy".into(),
2181 choices: vec![ChatChoice {
2182 index: 0,
2183 message: ChatCompletionResponseMessage {
2184 content: Some(ChatCompletionMessageContent::Text("hello".into())),
2185 refusal: None,
2186 tool_calls: None,
2187 role: Role::Assistant,
2188 function_call: None,
2189 audio: None,
2190 reasoning_content: None,
2191 },
2192 finish_reason: Some(FinishReason::Stop),
2193 logprobs: None,
2194 }],
2195 created: 0,
2196 model: "dummy-model".into(),
2197 service_tier: None,
2198 system_fingerprint: None,
2199 object: "chat.completion".into(),
2200 usage: None,
2201 };
2202
2203 let json = serde_json::to_value(response).unwrap();
2204
2205 for absent in ["usage", "service_tier", "system_fingerprint"] {
2206 assert!(json.get(absent).is_none(), "{absent} should be omitted");
2207 }
2208 let choice = &json["choices"][0];
2209 assert_eq!(choice["finish_reason"], "stop");
2210 assert_eq!(choice["logprobs"], serde_json::Value::Null);
2211 let message = &choice["message"];
2212 assert_eq!(message["refusal"], serde_json::Value::Null);
2213 for absent in ["tool_calls", "function_call", "audio", "reasoning_content"] {
2214 assert!(
2215 message.get(absent).is_none(),
2216 "message.{absent} should be omitted"
2217 );
2218 }
2219 }
2220
2221 #[test]
2222 fn stream_response_omits_absent_optional_fields() {
2223 let chunk = CreateChatCompletionStreamResponse {
2224 id: "chatcmpl_dummy".into(),
2225 choices: vec![ChatChoiceStream {
2226 index: 0,
2227 delta: ChatCompletionStreamResponseDelta {
2228 content: Some(ChatCompletionMessageContent::Text("hello".into())),
2229 function_call: None,
2230 tool_calls: None,
2231 role: None,
2232 refusal: None,
2233 reasoning_content: None,
2234 },
2235 finish_reason: None,
2236 logprobs: None,
2237 }],
2238 created: 0,
2239 model: "dummy-model".into(),
2240 service_tier: None,
2241 system_fingerprint: None,
2242 object: "chat.completion.chunk".into(),
2243 usage: None,
2244 };
2245
2246 let json = serde_json::to_value(chunk).unwrap();
2247
2248 for absent in ["usage", "service_tier", "system_fingerprint"] {
2249 assert!(json.get(absent).is_none(), "{absent} should be omitted");
2250 }
2251 }
2252
2253 #[test]
2254 fn stream_tool_call_continuation_chunk_omits_absent_fields() {
2255 let chunk = ChatCompletionMessageToolCallChunk {
2256 index: 0,
2257 id: None,
2258 r#type: None,
2259 function: Some(FunctionCallStream {
2260 name: None,
2261 arguments: Some("{\"a\":".into()),
2262 }),
2263 };
2264
2265 let json = serde_json::to_value(chunk).unwrap();
2266
2267 assert!(json.get("id").is_none());
2268 assert!(json.get("type").is_none());
2269 assert!(json["function"].get("name").is_none());
2270 assert_eq!(json["function"]["arguments"], "{\"a\":");
2271 }
2272
2273 #[test]
2274 fn stream_delta_function_call_omits_absent_fields() {
2275 let function_call = ChatCompletionStreamResponseDeltaFunctionCall {
2276 name: None,
2277 arguments: Some("{}".into()),
2278 };
2279
2280 let json = serde_json::to_value(function_call).unwrap();
2281
2282 assert!(json.get("name").is_none());
2283 assert_eq!(json["arguments"], "{}");
2284 }
2285
2286 #[test]
2287 fn usage_details_omit_absent_fields() {
2288 let response = CreateChatCompletionResponse {
2289 id: "chatcmpl_dummy".into(),
2290 choices: vec![],
2291 created: 0,
2292 model: "dummy-model".into(),
2293 service_tier: None,
2294 system_fingerprint: None,
2295 object: "chat.completion".into(),
2296 usage: Some(CompletionUsage {
2297 prompt_tokens: 10,
2298 completion_tokens: 25,
2299 total_tokens: 35,
2300 prompt_tokens_details: Some(PromptTokensDetails {
2301 audio_tokens: None,
2302 cached_tokens: Some(0),
2303 ..Default::default()
2304 }),
2305 completion_tokens_details: Some(CompletionTokensDetails {
2306 reasoning_tokens: Some(5),
2307 ..Default::default()
2308 }),
2309 }),
2310 };
2311
2312 let json = serde_json::to_value(&response).unwrap();
2313 let usage = &json["usage"];
2314
2315 assert_eq!(usage["total_tokens"], 35);
2316 assert_eq!(usage["prompt_tokens_details"]["cached_tokens"], 0);
2317 assert!(
2318 usage["prompt_tokens_details"].get("audio_tokens").is_none(),
2319 "audio_tokens should be omitted, not null"
2320 );
2321 assert_eq!(usage["completion_tokens_details"]["reasoning_tokens"], 5);
2322 for absent in [
2323 "accepted_prediction_tokens",
2324 "audio_tokens",
2325 "rejected_prediction_tokens",
2326 ] {
2327 assert!(
2328 usage["completion_tokens_details"].get(absent).is_none(),
2329 "{absent} should be omitted"
2330 );
2331 }
2332
2333 let roundtrip: CreateChatCompletionResponse = serde_json::from_value(json).unwrap();
2334 assert_eq!(roundtrip, response);
2335 }
2336
2337 #[test]
2340 fn effective_tool_set_unions_top_level_and_dynamic_system_tools() {
2341 let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2342 "model": "dummy-kimi-model",
2343 "tools": [{
2344 "type": "function",
2345 "function": {"name": "add", "parameters": {"type": "object"}}
2346 }],
2347 "messages": [
2348 {"role": "user", "content": "start"},
2349 {
2350 "role": "system",
2351 "tools": [
2352 {
2353 "type": "function",
2354 "function": {"name": "lookup", "parameters": {"type": "object"}}
2355 },
2356 {"name": "search", "parameters": {"type": "object"}},
2357 {"description": "no name, skipped"}
2358 ]
2359 },
2360 {"role": "user", "content": "continue"}
2361 ]
2362 }))
2363 .unwrap();
2364
2365 assert!(request.has_effective_tools());
2366 assert_eq!(request.dynamic_system_tools().count(), 3);
2367 assert_eq!(
2368 request.effective_tool_names().collect::<Vec<_>>(),
2369 ["add", "lookup", "search"],
2370 "top-level first, then dynamic in message order; wrapped and bare shapes both resolve"
2371 );
2372 for name in ["add", "lookup", "search"] {
2373 assert!(
2374 request.effective_tool_contains(name),
2375 "{name} should be found"
2376 );
2377 }
2378 assert!(!request.effective_tool_contains("missing"));
2379 assert!(
2380 !request.effective_tool_contains("no name, skipped"),
2381 "a description is not a name"
2382 );
2383 }
2384
2385 #[test]
2386 fn effective_tool_set_is_empty_without_any_declaration() {
2387 for payload in [
2388 serde_json::json!({
2389 "model": "m",
2390 "messages": [{"role": "user", "content": "hi"}]
2391 }),
2392 serde_json::json!({
2393 "model": "m",
2394 "tools": [],
2395 "messages": [{"role": "system", "content": "plain system text"}]
2396 }),
2397 ] {
2398 let request: CreateChatCompletionRequest = serde_json::from_value(payload).unwrap();
2399 assert!(!request.has_effective_tools());
2400 assert_eq!(request.effective_tool_names().count(), 0);
2401 assert!(!request.effective_tool_contains("anything"));
2402 }
2403 }
2404
2405 #[test]
2406 fn dynamic_system_tools_alone_count_as_effective_tools() {
2407 let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2408 "model": "dummy-kimi-model",
2409 "messages": [
2410 {"role": "system", "tools": [{"name": "lookup"}]},
2411 {"role": "user", "content": "go"}
2412 ]
2413 }))
2414 .unwrap();
2415
2416 assert!(
2417 request.tools.is_none(),
2418 "nothing was folded into top-level tools"
2419 );
2420 assert!(request.has_effective_tools());
2421 assert!(request.effective_tool_contains("lookup"));
2422 }
2423
2424 #[test]
2425 fn dynamic_tool_name_handles_wrapped_bare_and_invalid_shapes() {
2426 assert_eq!(
2427 dynamic_tool_name(&serde_json::json!({"type": "function", "function": {"name": "a"}})),
2428 Some("a")
2429 );
2430 assert_eq!(
2431 dynamic_tool_name(&serde_json::json!({"name": "b"})),
2432 Some("b")
2433 );
2434 assert_eq!(dynamic_tool_name(&serde_json::json!({"name": 7})), None);
2435 }
2436
2437 #[test]
2438 fn system_message_without_content_is_rejected_unless_it_declares_tools() {
2439 for (label, message) in [
2442 ("nothing", serde_json::json!({"role": "system"})),
2443 (
2444 "empty tools",
2445 serde_json::json!({"role": "system", "tools": []}),
2446 ),
2447 ] {
2448 let error =
2449 serde_json::from_value::<ChatCompletionRequestMessage>(message).expect_err(label);
2450 assert!(
2451 error.to_string().starts_with("missing field `content`"),
2452 "{label}: unexpected error {error}"
2453 );
2454 }
2455 }
2456
2457 #[test]
2458 fn system_message_guard_leaves_valid_shapes_alone() {
2459 for (label, message) in [
2460 (
2461 "content only",
2462 serde_json::json!({"role": "system", "content": "hi"}),
2463 ),
2464 (
2465 "content parts",
2466 serde_json::json!({"role": "system", "content": [{"type": "text", "text": "hi"}]}),
2467 ),
2468 (
2469 "tools only",
2470 serde_json::json!({"role": "system", "tools": [{"name": "lookup"}]}),
2471 ),
2472 (
2473 "content and tools (renderer decides)",
2474 serde_json::json!({"role": "system", "content": "hi", "tools": [{"name": "lookup"}]}),
2475 ),
2476 ] {
2477 let parsed: ChatCompletionRequestMessage =
2478 serde_json::from_value(message).unwrap_or_else(|e| panic!("{label}: {e}"));
2479 assert!(
2480 matches!(parsed, ChatCompletionRequestMessage::System(_)),
2481 "{label}"
2482 );
2483 }
2484 }
2485
2486 #[test]
2487 fn message_rejects_tools_and_partial_on_wrong_roles() {
2488 let tools = serde_json::json!([{"name": "lookup"}]);
2489 for (label, message, needle) in [
2490 (
2491 "tools on user",
2492 serde_json::json!({"role": "user", "content": "hi", "tools": tools}),
2493 "`tools` is only accepted on system messages, not on role user",
2494 ),
2495 (
2496 "tools on assistant",
2497 serde_json::json!({"role": "assistant", "content": "hi", "tools": tools}),
2498 "`tools` is only accepted on system messages, not on role assistant",
2499 ),
2500 (
2501 "tools on developer",
2503 serde_json::json!({"role": "developer", "content": "hi", "tools": tools}),
2504 "`tools` is only accepted on system messages, not on role developer",
2505 ),
2506 (
2507 "partial on user",
2508 serde_json::json!({"role": "user", "content": "hi", "partial": true}),
2509 "`partial` is only accepted on assistant messages, not on role user",
2510 ),
2511 (
2512 "partial on system",
2513 serde_json::json!({"role": "system", "content": "hi", "partial": false}),
2514 "`partial` is only accepted on assistant messages, not on role system",
2515 ),
2516 ] {
2517 let error = serde_json::from_value::<ChatCompletionRequestMessage>(message)
2518 .expect_err(label)
2519 .to_string();
2520 assert!(error.contains(needle), "{label}: {error}");
2521 }
2522
2523 for message in [
2524 serde_json::json!({"role": "user", "content": "hi", "tools": null}),
2525 serde_json::json!({"role": "user", "content": "hi", "partial": null}),
2526 ] {
2527 serde_json::from_value::<ChatCompletionRequestMessage>(message).unwrap();
2528 }
2529
2530 for message in [
2531 serde_json::json!({"role": "system", "tools": tools}),
2532 serde_json::json!({"role": "assistant", "content": "seed", "partial": true}),
2533 serde_json::json!({"role": "user", "content": "hi", "x_vendor": 1}),
2534 ] {
2535 serde_json::from_value::<ChatCompletionRequestMessage>(message).unwrap();
2536 }
2537 }
2538
2539 #[test]
2540 fn message_rejects_duplicate_top_level_keys() {
2541 for (label, raw) in [
2542 (
2543 "role twice",
2544 r#"{"role":"user","content":"hi","role":"system"}"#,
2545 ),
2546 (
2547 "content twice",
2548 r#"{"role":"user","content":"a","content":"b"}"#,
2549 ),
2550 ] {
2551 let error = serde_json::from_str::<ChatCompletionRequestMessage>(raw)
2552 .expect_err(label)
2553 .to_string();
2554 assert!(error.contains("duplicate field"), "{label}: {error}");
2555 }
2556 }
2557
2558 #[test]
2559 fn message_rejects_duplicate_fields_in_nested_typed_objects() {
2560 let tool_call = r#"{
2561 "role":"assistant",
2562 "content":null,
2563 "tool_calls":[{
2564 "id":"first",
2565 "id":"second",
2566 "type":"function",
2567 "function":{"name":"lookup","arguments":"{}"}
2568 }]
2569 }"#;
2570 let error = serde_json::from_str::<ChatCompletionRequestMessage>(tool_call)
2571 .unwrap_err()
2572 .to_string();
2573 assert!(error.contains("duplicate field `id`"), "{error}");
2574
2575 let content_part = r#"{
2576 "role":"user",
2577 "content":[{"type":"text","text":"first","text":"second"}]
2578 }"#;
2579 assert!(serde_json::from_str::<ChatCompletionRequestMessage>(content_part).is_err());
2580 }
2581
2582 #[test]
2583 fn default_system_message_round_trips() {
2584 let message = ChatCompletionRequestSystemMessage::default();
2585 let json = serde_json::to_value(&message).unwrap();
2586 assert_eq!(json, serde_json::json!({"content": ""}));
2587 let back: ChatCompletionRequestSystemMessage = serde_json::from_value(json).unwrap();
2588 assert_eq!(back, message);
2589
2590 let built = ChatCompletionRequestSystemMessageArgs::default()
2591 .name("ops")
2592 .build()
2593 .unwrap();
2594 let json = serde_json::to_value(&built).unwrap();
2595 assert_eq!(json, serde_json::json!({"content": "", "name": "ops"}));
2596 serde_json::from_value::<ChatCompletionRequestSystemMessage>(json).unwrap();
2597 }
2598
2599 #[test]
2600 fn system_message_guard_keeps_field_level_errors() {
2601 let error = serde_json::from_value::<ChatCompletionRequestMessage>(serde_json::json!({
2602 "role": "system",
2603 "tools": "lookup"
2604 }))
2605 .unwrap_err();
2606 assert!(
2607 !error.to_string().starts_with("missing field `content`"),
2608 "field error expected, got {error}"
2609 );
2610 }
2611
2612 #[test]
2613 fn system_message_canonicalizes_missing_content_with_tools() {
2614 let request: CreateChatCompletionRequest = serde_json::from_value(serde_json::json!({
2615 "model": "dummy-kimi-model",
2616 "messages": [
2617 {
2618 "role": "system",
2619 "tools": [
2620 {
2621 "name": "lookup",
2622 "description": "dummy lookup tool",
2623 "parameters": {
2624 "type": "object",
2625 "properties": {
2626 "query": { "type": "string" }
2627 }
2628 }
2629 }
2630 ]
2631 },
2632 {
2633 "role": "assistant",
2634 "content": "synthetic prefill",
2635 "partial": true
2636 },
2637 {
2638 "role": "user",
2639 "content": "continue"
2640 }
2641 ]
2642 }))
2643 .unwrap();
2644
2645 match &request.messages[0] {
2646 ChatCompletionRequestMessage::System(system) => {
2647 assert_eq!(
2648 system.content,
2649 ChatCompletionRequestSystemMessageContent::Text(String::new())
2650 );
2651 let tools = system.tools.as_ref().expect("tools should be present");
2652 assert_eq!(tools.len(), 1);
2653 assert_eq!(tools[0]["name"], "lookup");
2654 }
2655 other => panic!("expected system message, got {other:?}"),
2656 }
2657
2658 match &request.messages[1] {
2659 ChatCompletionRequestMessage::Assistant(assistant) => {
2660 assert_eq!(assistant.partial, Some(true));
2661 }
2662 other => panic!("expected assistant message, got {other:?}"),
2663 }
2664
2665 let message: ChatCompletionRequestMessage = serde_json::from_value(serde_json::json!({
2668 "role": "system",
2669 "content": null,
2670 "tools": [{"name": "lookup"}]
2671 }))
2672 .unwrap();
2673 let ChatCompletionRequestMessage::System(system) = &message else {
2674 panic!("expected system message");
2675 };
2676 assert_eq!(
2677 system.content,
2678 ChatCompletionRequestSystemMessageContent::Text(String::new())
2679 );
2680 assert_eq!(
2681 serde_json::to_value(message).unwrap(),
2682 serde_json::json!({
2683 "role": "system",
2684 "content": "",
2685 "tools": [{"name": "lookup"}]
2686 })
2687 );
2688 }
2689
2690 #[test]
2691 fn kimi_style_request_preserves_tools_and_canonicalizes_content() {
2692 let payload = serde_json::json!({
2693 "model": "dummy-kimi-model",
2694 "messages": [
2695 {
2696 "role": "system",
2697 "tools": [
2698 {
2699 "name": "lookup",
2700 "description": "dummy lookup tool",
2701 "parameters": {
2702 "type": "object",
2703 "properties": {
2704 "query": { "type": "string" }
2705 }
2706 },
2707 "vendor_hint": { "priority": 3 }
2708 }
2709 ]
2710 },
2711 {
2712 "role": "assistant",
2713 "content": "synthetic prefill",
2714 "partial": true
2715 },
2716 {
2717 "role": "user",
2718 "content": "continue"
2719 }
2720 ]
2721 });
2722
2723 let request: CreateChatCompletionRequest = serde_json::from_value(payload.clone()).unwrap();
2724 let serialized = serde_json::to_value(request).unwrap();
2725 let mut canonical = payload;
2726 canonical["messages"][0]["content"] = serde_json::json!("");
2727
2728 assert_eq!(serialized, canonical);
2729 }
2730
2731 #[test]
2732 fn system_message_tools_preserve_official_wrapped_shape() {
2733 let payload = serde_json::json!({
2734 "model": "dummy-kimi-model",
2735 "messages": [
2736 {
2737 "role": "system",
2738 "tools": [
2739 {
2740 "type": "function",
2741 "function": {
2742 "name": "lookup",
2743 "description": "dummy lookup tool",
2744 "parameters": {
2745 "type": "object",
2746 "properties": {
2747 "query": { "type": "string" }
2748 },
2749 "required": ["query"]
2750 },
2751 "strict": true
2752 }
2753 }
2754 ]
2755 },
2756 { "role": "user", "content": "continue" }
2757 ]
2758 });
2759
2760 let request: CreateChatCompletionRequest = serde_json::from_value(payload.clone()).unwrap();
2761 match &request.messages[0] {
2762 ChatCompletionRequestMessage::System(system) => {
2763 let tools = system.tools.as_ref().expect("tools should be present");
2764 assert_eq!(tools[0]["type"], "function");
2765 assert_eq!(tools[0]["function"]["name"], "lookup");
2766 }
2767 other => panic!("expected system message, got {other:?}"),
2768 }
2769
2770 let mut canonical = payload;
2771 canonical["messages"][0]["content"] = serde_json::json!("");
2772 assert_eq!(serde_json::to_value(request).unwrap(), canonical);
2773 }
2774
2775 #[test]
2776 fn assistant_message_omits_partial_when_absent() {
2777 let assistant = ChatCompletionRequestAssistantMessageArgs::default()
2778 .content("hello")
2779 .build()
2780 .unwrap();
2781
2782 assert_eq!(assistant.partial, None);
2783 let json = serde_json::to_value(&assistant).unwrap();
2784 assert!(
2785 json.get("partial").is_none(),
2786 "partial should be omitted when absent"
2787 );
2788 }
2789
2790 #[test]
2791 fn assistant_message_serializes_partial_when_present() {
2792 let assistant = ChatCompletionRequestAssistantMessageArgs::default()
2793 .content("synthetic prefill")
2794 .partial(true)
2795 .build()
2796 .unwrap();
2797
2798 let json = serde_json::to_value(&assistant).unwrap();
2799 assert_eq!(json["partial"], true);
2800
2801 let roundtrip: ChatCompletionRequestAssistantMessage =
2802 serde_json::from_value(json).unwrap();
2803 assert_eq!(roundtrip, assistant);
2804 }
2805
2806 #[test]
2807 fn system_message_from_upstream_preserves_content_and_leaves_tools_none() {
2808 let upstream = async_openai::types::chat::ChatCompletionRequestSystemMessage {
2809 content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
2810 "hi".into(),
2811 ),
2812 name: None,
2813 };
2814
2815 let owned: ChatCompletionRequestSystemMessage = upstream.into();
2816 assert!(owned.tools.is_none());
2817 match owned.content {
2818 ChatCompletionRequestSystemMessageContent::Text(text) => assert_eq!(text, "hi"),
2819 other => panic!("expected text content, got {other:?}"),
2820 }
2821 }
2822
2823 #[test]
2824 fn system_message_restores_upstream_convenience_conversions() {
2825 let from_content = ChatCompletionRequestSystemMessage::from(
2826 ChatCompletionRequestSystemMessageContent::Text("from content".into()),
2827 );
2828 let from_str = ChatCompletionRequestSystemMessage::from("from str");
2829 let from_string = ChatCompletionRequestSystemMessage::from(String::from("from string"));
2830
2831 for (message, expected) in [
2832 (from_content, "from content"),
2833 (from_str, "from str"),
2834 (from_string, "from string"),
2835 ] {
2836 assert_eq!(
2837 message.content,
2838 ChatCompletionRequestSystemMessageContent::Text(expected.into())
2839 );
2840 assert!(message.name.is_none());
2841 assert!(message.tools.is_none());
2842 }
2843 }
2844}