use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::types::UsageMetadata;
pub const DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
pub const SONNET_MODEL: &str = "claude-sonnet-4-6";
pub const OPUS_MODEL: &str = "claude-opus-4-8";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_MAX_TOKENS: u32 = 8192;
#[derive(Debug, Clone, Serialize, Default)]
pub struct MessagesRequest {
pub model: String,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub system: Vec<SystemBlock>,
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tools: Vec<ToolDef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
}
impl MessagesRequest {
pub fn system_from(prompt: Option<String>) -> Vec<SystemBlock> {
match prompt {
Some(text) if !text.is_empty() => vec![SystemBlock {
kind: "text",
text,
cache_control: Some(CacheControl::ephemeral()),
}],
_ => Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SystemBlock {
#[serde(rename = "type")]
pub kind: &'static str,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CacheControl {
#[serde(rename = "type")]
pub kind: &'static str,
}
impl CacheControl {
pub fn ephemeral() -> Self {
Self { kind: "ephemeral" }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Message {
pub role: Role,
pub content: Vec<Block>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Block {
Text { text: String },
Thinking {
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
#[serde(default)]
input: Value,
},
ToolResult {
tool_use_id: String,
#[serde(serialize_with = "serialize_tool_result_content")]
content: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
Image { source: ImageSource },
#[serde(other)]
Other,
}
fn serialize_tool_result_content<S: serde::Serializer>(v: &Value, s: S) -> Result<S::Ok, S::Error> {
match v {
Value::String(_) | Value::Array(_) => v.serialize(s),
other => s.serialize_str(&other.to_string()),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolChoice {
Auto,
Any,
Tool { name: String },
None,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ThinkingConfig {
#[serde(rename = "type")]
pub kind: String,
pub budget_tokens: u32,
}
impl ThinkingConfig {
pub fn enabled(budget_tokens: u32) -> Self {
Self {
kind: "enabled".to_string(),
budget_tokens,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct MessagesResponse {
#[serde(default)]
pub id: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub role: Option<Role>,
#[serde(default)]
pub content: Vec<Block>,
#[serde(default)]
pub stop_reason: Option<StopReason>,
#[serde(default)]
pub usage: Option<WireUsage>,
}
impl MessagesResponse {
pub fn text(&self) -> String {
let mut out = String::new();
for block in &self.content {
if let Block::Text { text } = block {
out.push_str(text);
}
}
out
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
StopSequence,
Refusal,
PauseTurn,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
pub struct WireUsage {
#[serde(default)]
pub input_tokens: Option<i32>,
#[serde(default)]
pub output_tokens: Option<i32>,
#[serde(default)]
pub cache_read_input_tokens: Option<i32>,
#[serde(default)]
pub cache_creation_input_tokens: Option<i32>,
}
impl From<WireUsage> for UsageMetadata {
fn from(w: WireUsage) -> Self {
let total = match (w.input_tokens, w.output_tokens) {
(Some(i), Some(o)) => Some(i + o),
(Some(i), None) => Some(i),
(None, Some(o)) => Some(o),
(None, None) => None,
};
UsageMetadata {
prompt_token_count: w.input_tokens,
cached_content_token_count: w.cache_read_input_tokens,
candidates_token_count: w.output_tokens,
thoughts_token_count: None,
total_token_count: total,
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StreamEvent {
MessageStart {
message: StreamMessage,
},
ContentBlockStart {
index: u32,
content_block: Block,
},
ContentBlockDelta {
index: u32,
delta: BlockDelta,
},
ContentBlockStop {
index: u32,
},
MessageDelta {
delta: MessageDeltaBody,
#[serde(default)]
usage: Option<WireUsage>,
},
MessageStop,
Ping,
Error {
error: ApiError,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct StreamMessage {
#[serde(default)]
pub id: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub role: Option<Role>,
#[serde(default)]
pub usage: Option<WireUsage>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct MessageDeltaBody {
#[serde(default)]
pub stop_reason: Option<StopReason>,
#[serde(default)]
pub stop_sequence: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct ApiError {
#[serde(default, rename = "type")]
pub kind: String,
#[serde(default)]
pub message: String,
}
impl Message {
pub fn user_text(text: impl Into<String>) -> Self {
Self {
role: Role::User,
content: vec![Block::Text { text: text.into() }],
}
}
pub fn assistant_text(text: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: vec![Block::Text { text: text.into() }],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_text_block() {
let b: Block = serde_json::from_str(r#"{"type":"text","text":"hello"}"#).unwrap();
assert!(matches!(b, Block::Text { ref text } if text == "hello"));
}
#[test]
fn tool_result_object_content_serializes_to_string() {
let block = Block::ToolResult {
tool_use_id: "toolu_1".into(),
content: serde_json::json!({"contents": "fn main() {}", "lines": 1}),
is_error: None,
};
let wire: Value = serde_json::to_value(&block).unwrap();
assert_eq!(wire["type"], "tool_result");
assert!(
wire["content"].is_string(),
"object content must serialize as a string, got {}",
wire["content"]
);
let back: Value = serde_json::from_str(wire["content"].as_str().unwrap()).unwrap();
assert_eq!(back, serde_json::json!({"contents": "fn main() {}", "lines": 1}));
let s = Block::ToolResult {
tool_use_id: "toolu_2".into(),
content: Value::String("plain".into()),
is_error: None,
};
let sv: Value = serde_json::to_value(&s).unwrap();
assert_eq!(sv["content"], "plain");
}
#[test]
fn deserialize_tool_use_block() {
let json = r#"{"type":"tool_use","id":"toolu_1","name":"view_file","input":{"path":"x.txt"}}"#;
let b: Block = serde_json::from_str(json).unwrap();
match b {
Block::ToolUse { id, name, input } => {
assert_eq!(id, "toolu_1");
assert_eq!(name, "view_file");
assert_eq!(input["path"], "x.txt");
}
other => panic!("expected ToolUse, got {other:?}"),
}
}
#[test]
fn deserialize_unknown_block_type_does_not_error() {
let redacted = r#"{"type":"redacted_thinking","data":"EvwBCkYIB..."}"#;
let b: Block = serde_json::from_str(redacted)
.expect("unknown block type must decode into a fallback, not error");
assert!(matches!(b, Block::Other), "expected Block::Other, got {b:?}");
let ev: StreamEvent = serde_json::from_str(
r#"{"type":"content_block_start","index":1,"content_block":{"type":"redacted_thinking","data":"EvwBCkYIB..."}}"#,
)
.expect("content_block_start with an unknown block must decode, not error");
match ev {
StreamEvent::ContentBlockStart { index, content_block } => {
assert_eq!(index, 1);
assert!(matches!(content_block, Block::Other));
}
other => panic!("expected ContentBlockStart, got {other:?}"),
}
}
#[test]
fn deserialize_full_response_text_and_tool_use() {
let json = r#"{
"id": "msg_01",
"model": "claude-haiku-4-5-20251001",
"role": "assistant",
"content": [
{"type":"text","text":"Let me read it."},
{"type":"tool_use","id":"toolu_abc","name":"view_file","input":{"path":"main.rs"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 42, "output_tokens": 17, "cache_read_input_tokens": 8}
}"#;
let resp: MessagesResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.role, Some(Role::Assistant));
assert_eq!(resp.stop_reason, Some(StopReason::ToolUse));
assert_eq!(resp.text(), "Let me read it.");
let tool = resp
.content
.iter()
.find_map(|b| match b {
Block::ToolUse { id, name, input } => Some((id.clone(), name.clone(), input.clone())),
_ => None,
})
.expect("tool_use block present");
assert_eq!(tool.0, "toolu_abc");
assert_eq!(tool.1, "view_file");
assert_eq!(tool.2["path"], "main.rs");
let usage: UsageMetadata = resp.usage.unwrap().into();
assert_eq!(usage.prompt_token_count, Some(42));
assert_eq!(usage.candidates_token_count, Some(17));
assert_eq!(usage.cached_content_token_count, Some(8));
assert_eq!(usage.total_token_count, Some(59));
}
#[test]
fn deserialize_stream_events_tagged_on_type() {
let start: StreamEvent = serde_json::from_str(
r#"{"type":"message_start","message":{"id":"msg_1","model":"claude-haiku-4-5-20251001","role":"assistant","usage":{"input_tokens":10,"output_tokens":1}}}"#,
)
.unwrap();
match start {
StreamEvent::MessageStart { message } => {
assert_eq!(message.id, "msg_1");
assert_eq!(message.usage.unwrap().input_tokens, Some(10));
}
other => panic!("expected MessageStart, got {other:?}"),
}
let delta: StreamEvent = serde_json::from_str(
r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"pa"}}"#,
)
.unwrap();
match delta {
StreamEvent::ContentBlockDelta { index, delta } => {
assert_eq!(index, 0);
assert_eq!(
delta,
BlockDelta::InputJsonDelta {
partial_json: "{\"pa".to_string()
}
);
}
other => panic!("expected ContentBlockDelta, got {other:?}"),
}
let mdelta: StreamEvent = serde_json::from_str(
r#"{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":25}}"#,
)
.unwrap();
match mdelta {
StreamEvent::MessageDelta { delta, usage } => {
assert_eq!(delta.stop_reason, Some(StopReason::ToolUse));
assert_eq!(usage.unwrap().output_tokens, Some(25));
}
other => panic!("expected MessageDelta, got {other:?}"),
}
let ping: StreamEvent = serde_json::from_str(r#"{"type":"ping"}"#).unwrap();
assert_eq!(ping, StreamEvent::Ping);
let stop: StreamEvent = serde_json::from_str(r#"{"type":"message_stop"}"#).unwrap();
assert_eq!(stop, StreamEvent::MessageStop);
}
#[test]
fn request_serializes_required_fields() {
let req = MessagesRequest {
model: DEFAULT_MODEL.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
system: MessagesRequest::system_from(Some("be terse".to_string())),
messages: vec![Message::user_text("hi")],
tools: Vec::new(),
tool_choice: None,
stream: true,
temperature: None,
thinking: None,
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["model"], DEFAULT_MODEL);
assert_eq!(v["max_tokens"], DEFAULT_MAX_TOKENS);
assert_eq!(v["system"][0]["type"], "text");
assert_eq!(v["system"][0]["text"], "be terse");
assert_eq!(v["system"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(v["stream"], true);
assert_eq!(v["messages"][0]["role"], "user");
assert_eq!(v["messages"][0]["content"][0]["type"], "text");
assert!(v.get("tools").is_none());
assert!(v.get("thinking").is_none());
assert!(v.get("temperature").is_none());
assert!(v.get("tool_choice").is_none());
}
#[test]
fn empty_system_is_omitted_from_wire() {
assert!(MessagesRequest::system_from(None).is_empty());
assert!(MessagesRequest::system_from(Some(String::new())).is_empty());
let req = MessagesRequest {
model: DEFAULT_MODEL.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
system: MessagesRequest::system_from(None),
messages: vec![Message::user_text("hi")],
tools: Vec::new(),
tool_choice: None,
stream: false,
temperature: None,
thinking: None,
};
let v = serde_json::to_value(&req).unwrap();
assert!(v.get("system").is_none(), "empty system must be omitted");
}
#[test]
fn tool_def_cache_control_shape() {
let cached = ToolDef {
name: "view_file".into(),
description: "read a file".into(),
input_schema: serde_json::json!({"type": "object"}),
cache_control: Some(CacheControl::ephemeral()),
};
let v = serde_json::to_value(&cached).unwrap();
assert_eq!(v["cache_control"]["type"], "ephemeral");
let plain = ToolDef {
cache_control: None,
..cached
};
let pv = serde_json::to_value(&plain).unwrap();
assert!(pv.get("cache_control").is_none(), "uncached tool omits the marker");
}
}