use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Role {
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Message {
pub role: Role,
pub parts: Vec<Part>,
}
impl Message {
pub fn new(role: Role, parts: Vec<Part>) -> Self {
Self { role, parts }
}
pub fn user(text: impl Into<String>) -> Self {
Self::new(Role::User, vec![Part::Text(text.into())])
}
pub fn assistant(text: impl Into<String>) -> Self {
Self::new(Role::Assistant, vec![Part::Text(text.into())])
}
pub fn tool_response(response: ToolResponse) -> Self {
Self::new(Role::Tool, vec![Part::ToolResponse(response)])
}
pub fn text(&self) -> Option<String> {
joined_text(&self.parts)
}
pub fn tool_calls(&self) -> Vec<&ToolCall> {
self.parts
.iter()
.filter_map(|part| match part {
Part::ToolCall(call) => Some(call),
_ => None,
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Part {
Text(String),
ToolCall(ToolCall),
ToolResponse(ToolResponse),
Reasoning(String),
ReasoningSignature(String),
Media(Media),
ProviderData(ProviderData),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
signatures: Vec<String>,
}
impl ToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments,
signatures: Vec::new(),
}
}
pub fn signatures(&self) -> &[String] {
&self.signatures
}
pub fn with_signatures(mut self, signatures: Vec<String>) -> Self {
self.signatures = signatures;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ToolResponse {
pub call_id: String,
pub name: String,
#[serde(deserialize_with = "deserialize_tool_content")]
pub content: Vec<Part>,
}
impl ToolResponse {
pub fn new(call: &ToolCall, content: impl Into<ToolOutput>) -> Self {
Self {
call_id: call.id.clone(),
name: call.name.clone(),
content: content.into().parts,
}
}
pub fn text(&self) -> Option<String> {
joined_text(&self.content)
}
}
fn deserialize_tool_content<'de, D>(deserializer: D) -> Result<Vec<Part>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Content {
Text(String),
Parts(Vec<Part>),
}
Ok(match Content::deserialize(deserializer)? {
Content::Text(text) => vec![Part::Text(text)],
Content::Parts(parts) => parts,
})
}
fn joined_text(parts: &[Part]) -> Option<String> {
let texts: Vec<&str> = parts
.iter()
.filter_map(|part| match part {
Part::Text(text) => Some(text.as_str()),
_ => None,
})
.collect();
(!texts.is_empty()).then(|| texts.concat())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Media {
pub content_type: String,
pub source: MediaSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Media {
pub fn new(content_type: impl Into<String>, source: MediaSource) -> Self {
Self {
content_type: content_type.into(),
source,
name: None,
}
}
pub fn from_url(content_type: impl Into<String>, url: impl Into<String>) -> Self {
Self::new(content_type, MediaSource::Url(url.into()))
}
pub fn image_url(url: impl Into<String>) -> Self {
let url = url.into();
Self::from_url(image_type_from_url(&url), url)
}
pub fn from_bytes(content_type: impl Into<String>, bytes: impl AsRef<[u8]>) -> Self {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
Self::from_base64(content_type, data)
}
pub fn from_base64(content_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::new(content_type, MediaSource::Base64(data.into()))
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn is_image(&self) -> bool {
self.content_type.starts_with("image/")
}
}
fn image_type_from_url(url: &str) -> &'static str {
let path = url.split(['?', '#']).next().unwrap_or_default();
let extension = path
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.unwrap_or_default();
match extension.as_str() {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/jpeg",
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MediaSource {
Url(String),
Base64(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ProviderData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub data: Value,
}
impl ProviderData {
pub fn new(data: Value) -> Self {
Self {
provider: None,
model: None,
data,
}
}
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
self.provider = Some(provider.into());
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct ToolOutput {
pub parts: Vec<Part>,
}
impl ToolOutput {
pub fn from_text(text: impl Into<String>) -> Self {
Self::default().with_text(text)
}
pub fn from_media(media: Media) -> Self {
Self::default().with_media(media)
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.parts.push(Part::Text(text.into()));
self
}
pub fn with_media(mut self, media: Media) -> Self {
self.parts.push(Part::Media(media));
self
}
pub fn text(&self) -> Option<String> {
joined_text(&self.parts)
}
pub fn media(&self) -> Vec<&Media> {
self.parts
.iter()
.filter_map(|part| match part {
Part::Media(media) => Some(media),
_ => None,
})
.collect()
}
}
impl std::fmt::Display for ToolOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for part in &self.parts {
match part {
Part::Text(text) => f.write_str(text)?,
Part::Media(media) => write!(f, "[{}]", media.content_type)?,
_ => {}
}
}
Ok(())
}
}
impl From<String> for ToolOutput {
fn from(text: String) -> Self {
Self::from_text(text)
}
}
impl From<&str> for ToolOutput {
fn from(text: &str) -> Self {
Self::from_text(text)
}
}
impl From<Media> for ToolOutput {
fn from(media: Media) -> Self {
Self::from_media(media)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn every_part() -> Message {
let mut call = ToolCall::new("call_1", "echo", json!({"text": "hi"}));
call.signatures = vec!["sig".to_string()];
Message::new(
Role::Assistant,
vec![
Part::ReasoningSignature("signature".into()),
Part::Reasoning("thinking".into()),
Part::Text("hello".into()),
Part::ToolCall(call.clone()),
Part::ToolResponse(ToolResponse::new(&call, "result")),
Part::ToolResponse(ToolResponse::new(
&call,
ToolOutput::from_text("an image")
.with_media(Media::new("image/png", MediaSource::Base64("aGk=".into()))),
)),
Part::Media(Media::new("image/png", MediaSource::Base64("aGk=".into()))),
Part::Media(Media::new(
"image/png",
MediaSource::Url("https://example.com/a.png".into()),
)),
Part::ProviderData(ProviderData {
provider: Some("OpenRouter".into()),
model: Some("m".into()),
data: json!({"type": "reasoning.encrypted"}),
}),
],
)
}
#[test]
fn messages_round_trip_through_json() {
let message = every_part();
let json = serde_json::to_string(&message).unwrap();
assert_eq!(serde_json::from_str::<Message>(&json).unwrap(), message);
}
#[test]
fn tool_responses_stored_before_0_5_still_load() {
let json = r#"{"role":"tool","parts":[{"type":"tool_response",
"value":{"call_id":"call_1","name":"echo","content":"result"}}]}"#;
let message: Message = serde_json::from_str(json).unwrap();
let call = ToolCall::new("call_1", "echo", json!({}));
assert_eq!(
message,
Message::tool_response(ToolResponse::new(&call, "result"))
);
}
#[test]
fn image_url_guesses_the_type_from_the_extension() {
let types: Vec<String> = [
"https://example.com/a.png",
"https://example.com/a.PNG?size=2#top",
"https://example.com/a.webp",
"https://example.com/a.gif",
"https://example.com/a.jpeg",
"https://example.com/image",
]
.into_iter()
.map(|url| Media::image_url(url).content_type)
.collect();
assert_eq!(
types,
[
"image/png",
"image/png",
"image/webp",
"image/gif",
"image/jpeg",
"image/jpeg"
]
);
assert_eq!(
Media::image_url("https://example.com/a.png").source,
MediaSource::Url("https://example.com/a.png".into())
);
}
#[test]
fn json_format_is_stable() {
let message = Message::user("hi");
assert_eq!(
serde_json::to_value(&message).unwrap(),
json!({"role": "user", "parts": [{"type": "text", "value": "hi"}]})
);
}
}