use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UnifiedRole {
System,
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedMessage {
pub role: UnifiedRole,
pub content: Vec<UnifiedContentBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_details: Option<Vec<crate::llm::openrouter::types::ReasoningDetail>>,
}
impl UnifiedMessage {
pub fn text(role: UnifiedRole, text: impl Into<String>) -> Self {
Self {
role,
content: vec![UnifiedContentBlock::Text { text: text.into() }],
id: None,
timestamp: Some(chrono::Utc::now()),
reasoning: None,
reasoning_details: None,
}
}
pub fn user(text: impl Into<String>) -> Self {
Self::text(UnifiedRole::User, text)
}
pub fn system(text: impl Into<String>) -> Self {
Self::text(UnifiedRole::System, text)
}
pub fn assistant(text: impl Into<String>) -> Self {
Self::text(UnifiedRole::Assistant, text)
}
pub fn extract_text(&self) -> String {
self.content
.iter()
.filter_map(|block| match block {
UnifiedContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn extract_tool_calls(&self) -> Vec<UnifiedToolCall> {
self.content
.iter()
.filter_map(|block| match block {
UnifiedContentBlock::ToolUse { id, name, input } => Some(UnifiedToolCall {
id: id.clone(),
name: name.clone(),
input: input.clone(),
raw_input_json: Some(input.to_string()),
}),
_ => None,
})
.collect()
}
pub fn has_tool_calls(&self) -> bool {
self.content
.iter()
.any(|block| matches!(block, UnifiedContentBlock::ToolUse { .. }))
}
pub fn extract_reasoning(&self) -> Option<String> {
let reasoning_blocks: Vec<&str> = self
.content
.iter()
.filter_map(|block| match block {
UnifiedContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
_ => None,
})
.collect();
if reasoning_blocks.is_empty() {
None
} else {
Some(reasoning_blocks.join("\n"))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UnifiedContentBlock {
Text {
text: String,
},
Image {
source: ImageSource,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
Document {
source: DocumentSource,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
ToolUse {
id: String,
name: String,
input: JsonValue,
},
ToolResult {
tool_use_id: String,
content: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
Thinking {
thinking: String,
#[serde(skip_serializing_if = "Option::is_none")]
signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
#[serde(default)]
redacted: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageSource {
Base64 {
media_type: String,
data: String,
},
Url {
url: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DocumentSource {
Base64Pdf {
media_type: String,
data: String,
},
UrlPdf {
url: String,
},
Text {
media_type: String,
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedTool {
pub name: String,
pub description: String,
pub parameters: JsonValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedToolCall {
pub id: String,
pub name: String,
pub input: JsonValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_input_json: Option<String>,
}
impl UnifiedToolCall {
pub fn parse_input<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
serde_json::from_value(self.input.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
StopSequence,
PauseTurn,
Refusal,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UnifiedUsage {
pub input_tokens: u32,
pub output_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
}
impl UnifiedUsage {
pub fn total_tokens(&self) -> u32 {
self.input_tokens + self.output_tokens
}
pub fn effective_input_tokens(&self) -> u32 {
let cache_creation = self.cache_creation_input_tokens.unwrap_or(0) as i64;
let cache_read = self.cache_read_input_tokens.unwrap_or(0) as i64;
let base = self.input_tokens as i64 + cache_creation - cache_read;
if base <= 0 {
0
} else {
base as u32
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_unified_message_text() {
let msg = UnifiedMessage::user("Hello, world!");
assert_eq!(msg.role, UnifiedRole::User);
assert_eq!(msg.content.len(), 1);
assert_eq!(msg.extract_text(), "Hello, world!");
}
#[test]
fn test_unified_message_tool_calls() {
let msg = UnifiedMessage {
role: UnifiedRole::Assistant,
content: vec![
UnifiedContentBlock::Text {
text: "Checking weather...".to_string(),
},
UnifiedContentBlock::ToolUse {
id: "call_1".to_string(),
name: "get_weather".to_string(),
input: json!({"location": "Paris"}),
},
],
id: None,
timestamp: None,
reasoning: None,
reasoning_details: None,
};
assert!(msg.has_tool_calls());
let calls = msg.extract_tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
}
#[test]
fn test_unified_tool_serialization() {
let tool = UnifiedTool {
name: "test_tool".to_string(),
description: "A test tool".to_string(),
parameters: json!({
"type": "object",
"properties": {
"param1": {"type": "string"}
},
"required": ["param1"]
}),
};
let json = serde_json::to_string(&tool).unwrap();
let deserialized: UnifiedTool = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "test_tool");
}
#[test]
fn test_usage_calculations() {
let usage = UnifiedUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: Some(200),
cache_read_input_tokens: Some(150),
reasoning_tokens: Some(30),
};
assert_eq!(usage.total_tokens(), 150);
assert_eq!(usage.effective_input_tokens(), 150); }
#[test]
fn test_extract_reasoning_multiple_blocks() {
let msg = UnifiedMessage {
role: UnifiedRole::Assistant,
content: vec![
UnifiedContentBlock::Thinking {
thinking: "Step 1".to_string(),
signature: None,
encrypted_content: None,
redacted: false,
},
UnifiedContentBlock::Thinking {
thinking: "Step 2".to_string(),
signature: None,
encrypted_content: None,
redacted: false,
},
],
id: None,
timestamp: None,
reasoning: None,
reasoning_details: None,
};
assert_eq!(msg.extract_reasoning().unwrap(), "Step 1\nStep 2");
}
}