use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use validator::Validate;
#[derive(Clone, Serialize, Validate)]
pub struct ChatCompletionResponse {
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
pub id: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub choices: Option<Vec<Choice>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_result: Option<Vec<VideoResultItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_search: Option<Vec<WebSearchInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_filter: Option<Vec<ContentFilterInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_status: Option<TaskStatus>,
}
#[derive(Deserialize)]
struct ChatCompletionResponseWire {
#[serde(
default,
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
id: Option<String>,
#[serde(
default,
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
request_id: Option<String>,
created: Option<u64>,
model: Option<String>,
choices: Option<Vec<Choice>>,
usage: Option<Usage>,
video_result: Option<Vec<VideoResultItem>>,
web_search: Option<Vec<WebSearchInfo>>,
content_filter: Option<Vec<ContentFilterInfo>>,
task_status: Option<TaskStatus>,
}
impl<'de> Deserialize<'de> for ChatCompletionResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = ChatCompletionResponseWire::deserialize(deserializer)?;
let response = Self {
id: wire.id,
request_id: wire.request_id,
created: wire.created,
model: wire.model,
choices: wire.choices,
usage: wire.usage,
video_result: wire.video_result,
web_search: wire.web_search,
content_filter: wire.content_filter,
task_status: wire.task_status,
};
if response.id.is_none()
&& response.request_id.is_none()
&& response.created.is_none()
&& response.model.is_none()
&& response.choices.is_none()
&& response.usage.is_none()
&& response.video_result.is_none()
&& response.web_search.is_none()
&& response.content_filter.is_none()
&& response.task_status.is_none()
{
return Err(D::Error::custom(
"chat completion response contained no documented fields",
));
}
Ok(response)
}
}
impl std::fmt::Debug for ChatCompletionResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match serde_json::to_string_pretty(self) {
Ok(s) => f.write_str(&s),
Err(_) => f.debug_struct("ChatCompletionResponse").finish(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TaskStatus {
#[serde(rename = "PROCESSING", alias = "processing")]
Processing,
#[serde(rename = "SUCCESS", alias = "success")]
Success,
#[serde(rename = "FAIL", alias = "fail")]
Fail,
#[serde(other)]
Unknown,
}
impl TaskStatus {
pub fn as_str(&self) -> &'static str {
match self {
TaskStatus::Processing => "PROCESSING",
TaskStatus::Success => "SUCCESS",
TaskStatus::Fail => "FAIL",
TaskStatus::Unknown => "UNKNOWN",
}
}
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct Choice {
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct Message {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<MessageContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<AudioContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallMessage>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Parts(Vec<MessageContentPart>),
}
impl MessageContent {
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Text(text) => Some(text),
Self::Parts(_) => None,
}
}
pub fn as_parts(&self) -> Option<&[MessageContentPart]> {
match self {
Self::Text(_) => None,
Self::Parts(parts) => Some(parts),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct MessageContentPart {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<MessageContentPartType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageContentPartType {
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ToolCallMessage {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
pub id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<ToolFunction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp: Option<MCPMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ToolFunction {
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct MCPMessage {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
pub id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<MCPCallType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<MCPTool>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_json_string"
)]
pub arguments: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum MCPCallType {
McpListTools,
McpCall,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct MCPTool {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_schema: Option<MCPInputSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct MCPInputSchema {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<MCPInputType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_properties: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "lowercase")]
pub enum MCPInputType {
Object,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct AudioContent {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::serde_helpers::optional_string_from_number_or_string"
)]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct Usage {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct PromptTokensDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct WebSearchInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(url)]
pub link: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub publish_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refer: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct VideoResultItem {
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(url)]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(url)]
pub cover_image_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct ContentFilterInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(range(min = 0, max = 3))]
pub level: Option<i32>,
}
impl ChatCompletionResponse {
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn request_id(&self) -> Option<&str> {
self.request_id.as_deref()
}
pub fn created(&self) -> Option<u64> {
self.created
}
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
pub fn choices(&self) -> Option<&[Choice]> {
self.choices.as_deref()
}
pub fn usage(&self) -> Option<&Usage> {
self.usage.as_ref()
}
pub fn video_result(&self) -> Option<&[VideoResultItem]> {
self.video_result.as_deref()
}
pub fn web_search(&self) -> Option<&[WebSearchInfo]> {
self.web_search.as_deref()
}
pub fn content_filter(&self) -> Option<&[ContentFilterInfo]> {
self.content_filter.as_deref()
}
pub fn task_status(&self) -> Option<&TaskStatus> {
self.task_status.as_ref()
}
}
impl Choice {
pub fn index(&self) -> Option<i32> {
self.index
}
pub fn message(&self) -> Option<&Message> {
self.message.as_ref()
}
pub fn finish_reason(&self) -> Option<&str> {
self.finish_reason.as_deref()
}
}
impl Message {
pub fn role(&self) -> Option<&str> {
self.role.as_deref()
}
pub fn content(&self) -> Option<&MessageContent> {
self.content.as_ref()
}
pub fn content_str(&self) -> Option<&str> {
self.content.as_ref().and_then(MessageContent::as_str)
}
pub fn reasoning_content(&self) -> Option<&str> {
self.reasoning_content.as_deref()
}
pub fn audio(&self) -> Option<&AudioContent> {
self.audio.as_ref()
}
pub fn tool_calls(&self) -> Option<&[ToolCallMessage]> {
self.tool_calls.as_deref()
}
}
impl ToolCallMessage {
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn type_(&self) -> Option<&str> {
self.type_.as_deref()
}
pub fn function(&self) -> Option<&ToolFunction> {
self.function.as_ref()
}
pub fn mcp(&self) -> Option<&MCPMessage> {
self.mcp.as_ref()
}
}
impl ToolFunction {
pub fn name(&self) -> &str {
&self.name
}
pub fn arguments(&self) -> &str {
&self.arguments
}
}
impl MCPMessage {
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn type_(&self) -> Option<&MCPCallType> {
self.type_.as_ref()
}
pub fn server_label(&self) -> Option<&str> {
self.server_label.as_deref()
}
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
pub fn tools(&self) -> Option<&[MCPTool]> {
self.tools.as_deref()
}
pub fn arguments(&self) -> Option<&str> {
self.arguments.as_deref()
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn output(&self) -> Option<&serde_json::Value> {
self.output.as_ref()
}
}
impl MCPTool {
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn annotations(&self) -> Option<&serde_json::Value> {
self.annotations.as_ref()
}
pub fn input_schema(&self) -> Option<&MCPInputSchema> {
self.input_schema.as_ref()
}
}
impl MCPInputSchema {
pub fn type_(&self) -> Option<&MCPInputType> {
self.type_.as_ref()
}
pub fn properties(&self) -> Option<&serde_json::Value> {
self.properties.as_ref()
}
pub fn required(&self) -> Option<&[String]> {
self.required.as_deref()
}
pub fn additional_properties(&self) -> Option<bool> {
self.additional_properties
}
}
impl AudioContent {
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn data(&self) -> Option<&str> {
self.data.as_deref()
}
pub fn expires_at(&self) -> Option<&str> {
self.expires_at.as_deref()
}
}
impl Usage {
pub fn prompt_tokens(&self) -> Option<u32> {
self.prompt_tokens
}
pub fn completion_tokens(&self) -> Option<u32> {
self.completion_tokens
}
pub fn total_tokens(&self) -> Option<u32> {
self.total_tokens
}
pub fn prompt_tokens_details(&self) -> Option<&PromptTokensDetails> {
self.prompt_tokens_details.as_ref()
}
}
impl PromptTokensDetails {
pub fn cached_tokens(&self) -> Option<u32> {
self.cached_tokens
}
}
impl WebSearchInfo {
pub fn icon(&self) -> Option<&str> {
self.icon.as_deref()
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn link(&self) -> Option<&str> {
self.link.as_deref()
}
pub fn media(&self) -> Option<&str> {
self.media.as_deref()
}
pub fn publish_date(&self) -> Option<&str> {
self.publish_date.as_deref()
}
pub fn content(&self) -> Option<&str> {
self.content.as_deref()
}
pub fn refer(&self) -> Option<&str> {
self.refer.as_deref()
}
}
impl VideoResultItem {
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
pub fn cover_image_url(&self) -> Option<&str> {
self.cover_image_url.as_deref()
}
}
impl ContentFilterInfo {
pub fn role(&self) -> Option<&str> {
self.role.as_deref()
}
pub fn level(&self) -> Option<i32> {
self.level
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_function_requires_the_documented_string_fields() {
let tf: ToolFunction =
serde_json::from_str(r#"{"name":"f","arguments":"{\"a\":1}"}"#).unwrap();
assert_eq!(tf.name, "f");
assert_eq!(tf.arguments, r#"{"a":1}"#);
assert!(serde_json::from_str::<ToolFunction>(r#"{"arguments":"{}"}"#).is_err());
assert!(serde_json::from_str::<ToolFunction>(r#"{"name":"f"}"#).is_err());
assert!(serde_json::from_str::<ToolFunction>(r#"{"name":"f","arguments":{}}"#).is_err());
}
#[test]
fn mcp_message_arguments_lenient() {
let m: MCPMessage = serde_json::from_str(r#"{"id":"x","arguments":{}}"#).unwrap();
assert_eq!(m.arguments.as_deref(), Some("{}"));
let m: MCPMessage = serde_json::from_str(r#"{"id":"x","arguments":null}"#).unwrap();
assert!(m.arguments.is_none());
}
#[test]
fn optional_custom_deserialized_fields_may_be_omitted() {
let tool_call: ToolCallMessage = serde_json::from_str("{}").unwrap();
assert!(tool_call.id.is_none());
let mcp: MCPMessage = serde_json::from_str("{}").unwrap();
assert!(mcp.id.is_none());
assert!(mcp.arguments.is_none());
let audio: AudioContent = serde_json::from_str("{}").unwrap();
assert!(audio.id.is_none());
assert!(audio.expires_at.is_none());
}
#[test]
fn completion_response_rejects_empty_success_bodies() {
assert!(serde_json::from_str::<ChatCompletionResponse>("{}").is_err());
assert!(serde_json::from_str::<ChatCompletionResponse>(r#"{"id":null}"#).is_err());
assert!(serde_json::from_str::<ChatCompletionResponse>(r#"{"id":"task-1"}"#).is_ok());
assert!(serde_json::from_str::<ChatCompletionResponse>(r#"{"choices":[]}"#).is_ok());
}
#[test]
fn choice_fields_follow_their_optional_openapi_shape() {
let choice: Choice = serde_json::from_str("{}").unwrap();
assert_eq!(choice.index(), None);
assert!(choice.message().is_none());
}
#[test]
fn assistant_content_accepts_only_the_documented_union() {
let text: Message = serde_json::from_value(serde_json::json!({
"content": "hello"
}))
.unwrap();
assert_eq!(text.content_str(), Some("hello"));
let parts: Message = serde_json::from_value(serde_json::json!({
"content": [{"type": "text", "text": "hello"}]
}))
.unwrap();
assert_eq!(
parts
.content()
.and_then(MessageContent::as_parts)
.and_then(|parts| parts.first())
.and_then(|part| part.text.as_deref()),
Some("hello")
);
assert!(serde_json::from_value::<Message>(serde_json::json!({"content": 42})).is_err());
assert!(serde_json::from_value::<Message>(serde_json::json!({"content": {}})).is_err());
}
#[test]
fn mcp_call_type_known_values_round_trip() {
let m: MCPMessage = serde_json::from_str(r#"{"id":"x","type":"mcp_call"}"#).unwrap();
assert!(matches!(m.type_, Some(MCPCallType::McpCall)));
let m: MCPMessage = serde_json::from_str(r#"{"id":"x","type":"mcp_list_tools"}"#).unwrap();
assert!(matches!(m.type_, Some(MCPCallType::McpListTools)));
}
#[test]
fn mcp_call_type_unknown_value_falls_back_to_unknown() {
let m: MCPMessage =
serde_json::from_str(r#"{"id":"x","type":"mcp_future_transport"}"#).unwrap();
assert!(matches!(m.type_, Some(MCPCallType::Unknown)));
}
#[test]
fn mcp_input_type_unknown_value_falls_back_to_unknown() {
let s: MCPInputSchema = serde_json::from_str(r#"{"type":"array"}"#).unwrap();
assert!(matches!(s.type_, Some(MCPInputType::Unknown)));
let s: MCPInputSchema = serde_json::from_str(r#"{"type":"object"}"#).unwrap();
assert!(matches!(s.type_, Some(MCPInputType::Object)));
}
}