use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
Image {
source: ImageSource,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageSource {
Base64 {
media_type: String,
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl Message {
pub fn user<S: Into<String>>(content: S) -> Self {
Self {
role: Role::User,
content: MessageContent::Text(content.into()),
name: None,
metadata: None,
}
}
pub fn assistant<S: Into<String>>(content: S) -> Self {
Self {
role: Role::Assistant,
content: MessageContent::Text(content.into()),
name: None,
metadata: None,
}
}
pub fn system<S: Into<String>>(content: S) -> Self {
Self {
role: Role::System,
content: MessageContent::Text(content.into()),
name: None,
metadata: None,
}
}
pub fn tool_result<S: Into<String>>(tool_use_id: S, content: S) -> Self {
Self {
role: Role::Tool,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: tool_use_id.into(),
content: content.into(),
is_error: None,
}]),
name: None,
metadata: None,
}
}
pub fn text(&self) -> Option<&str> {
match &self.content {
MessageContent::Text(text) => Some(text),
MessageContent::Blocks(_) => None,
}
}
pub fn text_or_summary(&self) -> String {
match &self.content {
MessageContent::Text(text) => text.clone(),
MessageContent::Blocks(blocks) => {
let mut parts = Vec::new();
for block in blocks {
match block {
ContentBlock::Text { text } => {
parts.push(text.clone());
}
ContentBlock::ToolUse { name, input, .. } => {
parts.push(format!("[Called tool: {} with args: {}]", name, input));
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
if is_error == &Some(true) {
parts.push(format!("[Tool error: {}]", content));
} else {
parts.push(format!("[Tool result: {}]", content));
}
}
ContentBlock::Image { .. } => {
parts.push("[Image]".to_string());
}
}
}
parts.join("\n")
}
}
}
pub fn text_mut(&mut self) -> Option<&mut String> {
match &mut self.content {
MessageContent::Text(text) => Some(text),
MessageContent::Blocks(_) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub cache_creation_input_tokens: u32,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub cache_read_input_tokens: u32,
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
impl Usage {
pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
Self {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}
}
pub fn with_cache(
prompt_tokens: u32,
completion_tokens: u32,
cache_creation_input_tokens: u32,
cache_read_input_tokens: u32,
) -> Self {
Self {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TurnReport {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub cache_creation_input_tokens: u32,
pub cache_read_input_tokens: u32,
pub cost_usd_cents: Option<u64>,
pub duration_ms: u64,
}
impl TurnReport {
pub fn from_usage_delta(before: &Usage, after: &Usage, duration_ms: u64) -> Self {
Self {
prompt_tokens: after.prompt_tokens.saturating_sub(before.prompt_tokens),
completion_tokens: after
.completion_tokens
.saturating_sub(before.completion_tokens),
total_tokens: after.total_tokens.saturating_sub(before.total_tokens),
cache_creation_input_tokens: after
.cache_creation_input_tokens
.saturating_sub(before.cache_creation_input_tokens),
cache_read_input_tokens: after
.cache_read_input_tokens
.saturating_sub(before.cache_read_input_tokens),
cost_usd_cents: None,
duration_ms,
}
}
}
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub message: Message,
pub usage: Usage,
pub finish_reason: Option<String>,
}
pub fn serialize_messages_to_stateless_history(messages: &[Message]) -> Vec<Value> {
let mut history = Vec::new();
for msg in messages {
if msg.role == Role::System {
continue;
}
let role_str = match msg.role {
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
Role::System => continue, };
match &msg.content {
MessageContent::Text(text) => {
if msg.role == Role::Assistant && text.trim().is_empty() {
continue;
}
history.push(serde_json::json!({
"role": role_str,
"content": text,
}));
}
MessageContent::Blocks(blocks) => {
let mut text_parts = Vec::new();
for block in blocks {
match block {
ContentBlock::Text { text } => {
text_parts.push(text.clone());
}
ContentBlock::ToolUse { id, name, input } => {
if !text_parts.is_empty() {
let combined = text_parts.join("\n");
if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
history.push(serde_json::json!({
"role": role_str,
"content": combined,
}));
}
text_parts.clear();
}
history.push(serde_json::json!({
"role": "function_call",
"call_id": id,
"name": name,
"arguments": input.to_string(),
}));
}
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
if !text_parts.is_empty() {
let combined = text_parts.join("\n");
if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
history.push(serde_json::json!({
"role": role_str,
"content": combined,
}));
}
text_parts.clear();
}
history.push(serde_json::json!({
"role": "tool",
"call_id": tool_use_id,
"content": content,
}));
}
ContentBlock::Image { .. } => {
}
}
}
if !text_parts.is_empty() {
let combined = text_parts.join("\n");
if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
history.push(serde_json::json!({
"role": role_str,
"content": combined,
}));
}
}
}
}
}
history
}
#[derive(Debug, Clone)]
pub enum StreamChunk {
Text(String),
ToolUse {
id: String,
name: String,
},
ToolInputDelta {
id: String,
partial_json: String,
},
ToolCall {
call_id: String,
response_id: String,
chat_id: Option<String>,
tool_name: String,
server: String,
parameters: serde_json::Value,
},
Usage(Usage),
ContextCompacted {
summary: String,
tokens_freed: Option<u32>,
},
Done,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_message_user() {
let msg = Message::user("Hello");
assert_eq!(msg.role, Role::User);
assert_eq!(msg.text(), Some("Hello"));
}
#[test]
fn test_message_assistant() {
let msg = Message::assistant("Response");
assert_eq!(msg.role, Role::Assistant);
assert_eq!(msg.text(), Some("Response"));
}
#[test]
fn test_message_tool_result() {
let msg = Message::tool_result("tool-1", "Result");
assert_eq!(msg.role, Role::Tool);
}
#[test]
fn test_usage_new() {
let usage = Usage::new(100, 50);
assert_eq!(usage.total_tokens, 150);
}
#[test]
fn test_role_serialization() {
let role = Role::User;
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, "\"user\"");
}
#[test]
fn test_stateless_history_simple_text() {
let messages = vec![Message::user("Hello"), Message::assistant("Hi there")];
let history = serialize_messages_to_stateless_history(&messages);
assert_eq!(history.len(), 2);
assert_eq!(history[0]["role"], "user");
assert_eq!(history[1]["role"], "assistant");
}
#[test]
fn test_stateless_history_skips_system() {
let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
let history = serialize_messages_to_stateless_history(&messages);
assert_eq!(history.len(), 1);
assert_eq!(history[0]["role"], "user");
}
#[test]
fn test_stateless_history_tool_round_trip() {
let messages = vec![
Message::user("Read the file"),
Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![
ContentBlock::Text {
text: "I'll check.".to_string(),
},
ContentBlock::ToolUse {
id: "call-1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "main.rs"}),
},
]),
name: None,
metadata: None,
},
Message::tool_result("call-1", "fn main() {}"),
Message::assistant("The file contains a main function."),
];
let history = serialize_messages_to_stateless_history(&messages);
assert_eq!(history.len(), 5);
assert_eq!(history[0]["role"], "user");
assert_eq!(history[1]["role"], "assistant");
assert_eq!(history[2]["role"], "function_call");
assert_eq!(history[3]["role"], "tool");
assert_eq!(history[4]["role"], "assistant");
}
}