use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "lowercase")]
pub enum Message {
User { content: Vec<UserContent> },
Assistant { content: Vec<AssistantContent> },
}
impl Message {
pub fn user(text: impl Into<String>) -> Self {
Message::User {
content: vec![UserContent::text(text)],
}
}
pub fn assistant(text: impl Into<String>) -> Self {
Message::Assistant {
content: vec![AssistantContent::text(text)],
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum UserContent {
Text(Text),
ToolResult(ToolResult),
Image(Image),
}
impl UserContent {
pub fn text(text: impl Into<String>) -> Self {
UserContent::Text(Text { text: text.into() })
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AssistantContent {
Text(Text),
ToolCall(ToolCall),
Reasoning(Reasoning),
}
impl AssistantContent {
pub fn text(text: impl Into<String>) -> Self {
AssistantContent::Text(Text { text: text.into() })
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Text {
pub text: String,
}
impl From<String> for Text {
fn from(text: String) -> Self {
Text { text }
}
}
impl From<&str> for Text {
fn from(text: &str) -> Self {
Text { text: text.into() }
}
}
impl std::fmt::Display for Text {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.text)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call_id: Option<String>,
pub function: ToolFunction,
}
impl ToolCall {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
ToolCall {
id: id.into(),
call_id: None,
function: ToolFunction {
name: name.into(),
arguments,
},
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolResult {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call_id: Option<String>,
pub content: Vec<ToolResultContent>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ToolResultContent {
Text(Text),
Image(Image),
}
impl ToolResultContent {
pub fn text(text: impl Into<String>) -> Self {
ToolResultContent::Text(Text { text: text.into() })
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Reasoning {
pub id: Option<String>,
pub content: Vec<ReasoningContent>,
}
impl Reasoning {
pub fn new_with_signature(text: impl Into<String>, signature: Option<String>) -> Self {
Reasoning {
id: None,
content: vec![ReasoningContent::Text {
text: text.into(),
signature,
}],
}
}
pub fn first_text(&self) -> Option<&str> {
self.content.iter().find_map(|content| match content {
ReasoningContent::Text { text, .. } => Some(text.as_str()),
_ => None,
})
}
pub fn first_signature(&self) -> Option<&str> {
self.content.iter().find_map(|content| match content {
ReasoningContent::Text {
signature: Some(signature),
..
} => Some(signature.as_str()),
_ => None,
})
}
pub fn display_text(&self) -> String {
self.content
.iter()
.filter_map(|content| match content {
ReasoningContent::Text { text, .. } => Some(text.as_str()),
ReasoningContent::Summary(summary) => Some(summary.as_str()),
ReasoningContent::Redacted { data } => Some(data.as_str()),
ReasoningContent::Encrypted(_) => None,
})
.collect::<Vec<_>>()
.join("\n")
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "content", rename_all = "snake_case")]
pub enum ReasoningContent {
Text {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
Encrypted(String),
Redacted { data: String },
Summary(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Image {
pub data: ImageSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<ImageMediaType>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
pub enum ImageSource {
Url(String),
Base64(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageMediaType {
JPEG,
PNG,
GIF,
WEBP,
HEIC,
HEIF,
SVG,
}
impl ImageMediaType {
pub fn from_mime_type(mime_type: &str) -> Option<Self> {
match mime_type {
"image/jpeg" => Some(ImageMediaType::JPEG),
"image/png" => Some(ImageMediaType::PNG),
"image/gif" => Some(ImageMediaType::GIF),
"image/webp" => Some(ImageMediaType::WEBP),
"image/heic" => Some(ImageMediaType::HEIC),
"image/heif" => Some(ImageMediaType::HEIF),
"image/svg+xml" => Some(ImageMediaType::SVG),
_ => None,
}
}
pub fn to_mime_type(&self) -> &'static str {
match self {
ImageMediaType::JPEG => "image/jpeg",
ImageMediaType::PNG => "image/png",
ImageMediaType::GIF => "image/gif",
ImageMediaType::WEBP => "image/webp",
ImageMediaType::HEIC => "image/heic",
ImageMediaType::HEIF => "image/heif",
ImageMediaType::SVG => "image/svg+xml",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[track_caller]
fn assert_round_trip<T>(value: &T, json: &str)
where
T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
{
let serialized = serde_json::to_string(value).expect("serialize");
assert_eq!(serialized, json);
let deserialized: T = serde_json::from_str(json).expect("deserialize");
assert_eq!(&deserialized, value);
}
#[test]
fn user_text_message_format() {
assert_round_trip(
&Message::user("hi"),
r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#,
);
}
#[test]
fn assistant_text_message_format() {
assert_round_trip(
&Message::assistant("hello"),
r#"{"role":"assistant","content":[{"text":"hello"}]}"#,
);
}
#[test]
fn tool_call_format() {
assert_round_trip(
&AssistantContent::ToolCall(ToolCall::new(
"call-1",
"get_weather",
serde_json::json!({"city": "Berlin"}),
)),
r#"{"id":"call-1","function":{"name":"get_weather","arguments":{"city":"Berlin"}}}"#,
);
}
#[test]
fn tool_result_format() {
assert_round_trip(
&UserContent::ToolResult(ToolResult {
id: "call-1".to_owned(),
call_id: None,
content: vec![ToolResultContent::text("ok")],
}),
r#"{"type":"toolresult","id":"call-1","content":[{"type":"text","text":"ok"}]}"#,
);
}
#[test]
fn reasoning_format() {
assert_round_trip(
&AssistantContent::Reasoning(Reasoning::new_with_signature(
"thinking...",
Some("sig".to_owned()),
)),
r#"{"id":null,"content":[{"type":"text","content":{"text":"thinking...","signature":"sig"}}]}"#,
);
}
#[test]
fn reasoning_content_variants_round_trip() {
assert_round_trip(
&ReasoningContent::Encrypted("opaque".to_owned()),
r#"{"type":"encrypted","content":"opaque"}"#,
);
assert_round_trip(
&ReasoningContent::Redacted {
data: "r".to_owned(),
},
r#"{"type":"redacted","content":{"data":"r"}}"#,
);
assert_round_trip(
&ReasoningContent::Summary("s".to_owned()),
r#"{"type":"summary","content":"s"}"#,
);
}
#[test]
fn image_format() {
assert_round_trip(
&UserContent::Image(Image {
data: ImageSource::Base64("aGVsbG8=".to_owned()),
media_type: Some(ImageMediaType::PNG),
}),
r#"{"type":"image","data":{"type":"base64","value":"aGVsbG8="},"media_type":"png"}"#,
);
}
}