use crate::completion::StopReason;
use crate::MetadataMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum Message {
System {
content: String,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
meta: MetadataMap,
},
User {
content: Vec<Content>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
meta: MetadataMap,
},
Assistant {
content: Vec<Content>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stop_reason: Option<StopReason>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
meta: MetadataMap,
},
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self::System {
content: content.into(),
meta: MetadataMap::new(),
}
}
pub fn user_text(text: impl Into<String>) -> Self {
Self::User {
content: vec![Content::text(text)],
meta: MetadataMap::new(),
}
}
pub fn assistant_text(text: impl Into<String>) -> Self {
Self::Assistant {
content: vec![Content::text(text)],
stop_reason: None,
meta: MetadataMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Content {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
output: ToolOutput,
#[serde(default)]
is_error: bool,
},
Thinking {
thinking: String,
},
Image(ImageRef),
Document(DocumentRef),
Audio(AudioRef),
Citation(CitationRef),
}
impl Content {
pub fn text(s: impl Into<String>) -> Self {
Self::Text { text: s.into() }
}
pub fn thinking(s: impl Into<String>) -> Self {
Self::Thinking { thinking: s.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct ToolOutput {
pub content: Vec<Content>,
#[serde(default)]
pub truncated: bool,
}
impl ToolOutput {
pub fn text(s: impl Into<String>) -> Self {
Self {
content: vec![Content::text(s)],
truncated: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum ImageRef {
Url {
#[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
url: Url,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
File {
#[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
path: PathBuf,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
Inline {
mime: String,
bytes: Vec<u8>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum DocumentRef {
Url {
#[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
url: Url,
mime: Option<String>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
File {
#[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
path: PathBuf,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
Inline {
mime: String,
bytes: Vec<u8>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum AudioRef {
Url {
#[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
url: Url,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
File {
#[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
path: PathBuf,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
Inline {
mime: String,
bytes: Vec<u8>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
extensions: MetadataMap,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct CitationRef {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quoted_text: Option<String>,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
pub extensions: MetadataMap,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn round_trip(content: &Content) -> Content {
let bytes = serde_json::to_vec(content).expect("serialize Content");
serde_json::from_slice::<Content>(&bytes).expect("deserialize Content")
}
#[test]
fn text_serializes_as_tagged_struct() {
let c = Content::text("hello");
let v = serde_json::to_value(&c).expect("to_value");
assert_eq!(v, json!({"type": "text", "text": "hello"}));
assert!(matches!(round_trip(&c), Content::Text { text } if text == "hello"));
}
#[test]
fn thinking_serializes_as_tagged_struct() {
let c = Content::thinking("hmm");
let v = serde_json::to_value(&c).expect("to_value");
assert_eq!(v, json!({"type": "thinking", "thinking": "hmm"}));
assert!(matches!(round_trip(&c), Content::Thinking { thinking } if thinking == "hmm"));
}
#[test]
fn tool_use_round_trips() {
let c = Content::ToolUse {
id: "tu_1".into(),
name: "fs_list".into(),
input: json!({"path": "."}),
};
let v = serde_json::to_value(&c).expect("to_value");
assert_eq!(v["type"], "tool_use");
assert_eq!(v["id"], "tu_1");
assert_eq!(v["name"], "fs_list");
assert_eq!(v["input"], json!({"path": "."}));
assert!(matches!(round_trip(&c), Content::ToolUse { id, .. } if id == "tu_1"));
}
#[test]
fn tool_result_round_trips() {
let c = Content::ToolResult {
tool_use_id: "tu_1".into(),
output: ToolOutput::text("ok"),
is_error: false,
};
let bytes = serde_json::to_vec(&c).expect("serialize");
let back: Content = serde_json::from_slice(&bytes).expect("deserialize");
match back {
Content::ToolResult {
tool_use_id,
output,
is_error,
} => {
assert_eq!(tool_use_id, "tu_1");
assert!(!is_error);
assert!(!output.truncated);
assert!(
matches!(&output.content[..], [Content::Text { text }] if text == "ok"),
"output content: {:?}",
output.content
);
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn message_with_mixed_content_round_trips() {
let msg = Message::Assistant {
content: vec![
Content::text("Here's what I found:"),
Content::ToolUse {
id: "tu_1".into(),
name: "fs_list".into(),
input: json!({"path": "."}),
},
],
stop_reason: Some(StopReason::ToolUse),
meta: MetadataMap::new(),
};
let bytes = serde_json::to_vec(&msg).expect("serialize Message");
let back: Message = serde_json::from_slice(&bytes).expect("deserialize Message");
match back {
Message::Assistant { content, .. } => assert_eq!(content.len(), 2),
other => panic!("expected Assistant, got {other:?}"),
}
}
}