use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
#[serde(default)]
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextContentPart {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageContentPart {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
}
impl ImageContentPart {
pub fn from_url(url: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
base64: None,
media_type: None,
}
}
pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
Self {
url: None,
base64: Some(base64.into()),
media_type: Some(media_type.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text(TextContentPart),
Image(ImageContentPart),
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
ContentPart::Text(TextContentPart { text: text.into() })
}
pub fn image_url(url: impl Into<String>) -> Self {
ContentPart::Image(ImageContentPart::from_url(url))
}
pub fn image_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
ContentPart::Image(ImageContentPart::from_base64(base64, media_type))
}
pub fn as_text(&self) -> Option<&str> {
match self {
ContentPart::Text(part) => Some(&part.text),
ContentPart::Image(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<ContentPart>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl Message {
fn text_content(text: impl Into<String>) -> Vec<ContentPart> {
let text = text.into();
if text.is_empty() {
Vec::new()
} else {
vec![ContentPart::text(text)]
}
}
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: Self::text_content(text),
tool_calls: Vec::new(),
tool_call_id: None,
}
}
pub fn user_multimodal(content: Vec<ContentPart>) -> Self {
Self {
role: Role::User,
content,
tool_calls: Vec::new(),
tool_call_id: None,
}
}
pub fn assistant(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: Self::text_content(text),
tool_calls: Vec::new(),
tool_call_id: None,
}
}
pub fn assistant_with_calls(text: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self {
role: Role::Assistant,
content: Self::text_content(text),
tool_calls,
tool_call_id: None,
}
}
pub fn tool_result(call_id: impl Into<String>, text: impl Into<String>) -> Self {
Self {
role: Role::Tool,
content: Self::text_content(text),
tool_calls: Vec::new(),
tool_call_id: Some(call_id.into()),
}
}
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(ContentPart::as_text)
.collect::<Vec<_>>()
.join("")
}
pub fn is_text_only(&self) -> bool {
self.content
.iter()
.all(|part| matches!(part, ContentPart::Text(_)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_constructors_round_trip_through_text() {
assert_eq!(Message::user("hi").text(), "hi");
assert_eq!(Message::assistant("").text(), "");
assert!(Message::assistant("").content.is_empty());
}
#[test]
fn multimodal_message_mixes_text_and_image() {
let message = Message::user_multimodal(vec![
ContentPart::text("what is this?"),
ContentPart::image_url("https://example.com/cat.png"),
]);
assert_eq!(message.text(), "what is this?");
assert!(!message.is_text_only());
}
#[test]
fn serde_roundtrip_preserves_content_parts() {
let message = Message::user_multimodal(vec![
ContentPart::text("a"),
ContentPart::image_base64("Zm9v", "image/png"),
]);
let json = serde_json::to_string(&message).unwrap();
let back: Message = serde_json::from_str(&json).unwrap();
assert_eq!(message, back);
}
}