use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{ExtensionMap, ModelError, ModelErrorKind};
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum MediaSource {
Url {
url: String,
media_type: Option<String>,
},
Base64 {
media_type: String,
data: String,
},
Artifact {
artifact_id: String,
media_type: Option<String>,
},
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ProviderData {
pub provider: String,
pub kind: String,
pub value: Value,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Citation {
pub uri: Option<String>,
pub title: Option<String>,
pub start: Option<u64>,
pub end: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ReasoningPart {
pub text: Option<String>,
pub signature: Option<String>,
pub redacted: bool,
pub provider_data: Vec<ProviderData>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
pub raw_arguments: Option<String>,
pub metadata: ExtensionMap,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ToolResult {
pub call_id: String,
#[serde(default)]
pub name: Option<String>,
pub content: Vec<ContentPart>,
pub is_error: bool,
pub metadata: ExtensionMap,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ContentPart {
Text {
text: String,
},
Image {
source: MediaSource,
},
Audio {
source: MediaSource,
},
Document {
source: MediaSource,
name: Option<String>,
},
ToolCall(ToolCall),
ToolResult(ToolResult),
Reasoning(ReasoningPart),
Refusal {
text: String,
},
Citation(Citation),
ProviderOpaque(ProviderData),
}
impl ContentPart {
pub fn text(value: impl Into<String>) -> Self {
Self::Text { text: value.into() }
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentPart>,
pub metadata: BTreeMap<String, Value>,
}
impl Message {
pub fn new(role: Role, content: Vec<ContentPart>) -> Result<Self, ModelError> {
if content.is_empty() {
return Err(ModelError::local(
ModelErrorKind::InvalidRequest,
"a message must contain at least one content part",
));
}
Ok(Self {
role,
content,
metadata: BTreeMap::new(),
})
}
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![ContentPart::text(text)],
metadata: BTreeMap::new(),
}
}
pub fn system(text: impl Into<String>) -> Self {
Self {
role: Role::System,
content: vec![ContentPart::text(text)],
metadata: BTreeMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::{ContentPart, Message, Role, ToolResult};
use crate::ModelErrorKind;
#[test]
fn empty_messages_are_rejected() {
let error = Message::new(Role::User, Vec::new()).unwrap_err();
assert_eq!(error.kind, ModelErrorKind::InvalidRequest);
}
#[test]
fn content_round_trips_without_erasing_opaque_data() {
let message = Message::new(
Role::Assistant,
vec![
ContentPart::text("answer"),
ContentPart::ProviderOpaque(super::ProviderData {
provider: "example".into(),
kind: "future_block".into(),
value: serde_json::json!({"x": 1}),
}),
],
)
.unwrap();
let encoded = serde_json::to_value(&message).unwrap();
let decoded: Message = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, message);
}
#[test]
fn legacy_tool_results_without_a_name_still_deserialize() {
let result: ToolResult = serde_json::from_value(serde_json::json!({
"call_id":"call_1",
"content":[{"type":"text","text":"ok"}],
"is_error":false,
"metadata":{}
}))
.unwrap();
assert_eq!(result.name, None);
}
}