use serde_json::{Map, Value, json};
use crate::error::AgentError;
use crate::operations::agent::Model;
use crate::pricing::{CostBreakdown, StaticPricing};
use crate::provider::AgentConfig;
use crate::providers::http::adapter::{HttpAgentAdapter, HttpToolCall, HttpUsage, TurnResult};
use crate::providers::http::sse::SseDelta;
use crate::providers::http::tools::ToolRegistry;
use crate::schema_transform::transform_schema;
pub struct AnthropicModel;
impl AnthropicModel {
pub const FABLE_5_1: &str = "claude-fable-5-1";
pub const FABLE_5: &str = "claude-fable-5";
pub const MYTHOS_5_1: &str = "claude-mythos-5-1";
pub const MYTHOS_5: &str = "claude-mythos-5";
pub const OPUS_5_5: &str = "claude-opus-5-5";
pub const OPUS_5: &str = "claude-opus-5";
pub const SONNET_5_5: &str = "claude-sonnet-5-5";
pub const SONNET_5: &str = "claude-sonnet-5";
pub const OPUS_4_8: &str = "claude-opus-4-8";
pub const OPUS_4_7: &str = "claude-opus-4-7";
pub const SONNET_4_6: &str = "claude-sonnet-4-6";
pub const HAIKU_5_5: &str = "claude-haiku-5-5";
pub const HAIKU_4_5: &str = "claude-haiku-4-5-20251001";
pub const OPUS_4_6: &str = "claude-opus-4-6";
pub const SONNET_4_5: &str = "claude-sonnet-4-5-20250929";
}
const OUTPUT_CONFIG_FORMAT_MODELS: &[&str] = &[
AnthropicModel::SONNET_5_5,
AnthropicModel::OPUS_5_5,
AnthropicModel::FABLE_5_1,
AnthropicModel::MYTHOS_5_1,
];
fn requires_output_config_format(model: &str) -> bool {
OUTPUT_CONFIG_FORMAT_MODELS
.iter()
.any(|known| model.starts_with(known))
}
pub struct AnthropicApiAdapter {
api_key: String,
base_url: String,
anthropic_version: String,
default_model: String,
}
impl AnthropicApiAdapter {
pub fn new(api_key: String) -> Self {
Self {
api_key,
base_url: "https://api.anthropic.com".to_string(),
anthropic_version: "2023-06-01".to_string(),
default_model: AnthropicModel::SONNET_5.to_string(),
}
}
pub fn with_base_url(mut self, base_url: String) -> Self {
self.base_url = base_url;
self
}
pub fn with_default_model(mut self, model: String) -> Self {
self.default_model = model;
self
}
}
impl HttpAgentAdapter for AnthropicApiAdapter {
fn provider_name(&self) -> &'static str {
"anthropic"
}
fn endpoint_url(&self, _model: &str) -> String {
format!("{}/v1/messages", self.base_url)
}
fn auth_headers(&self) -> Vec<(String, String)> {
vec![
("x-api-key".to_string(), self.api_key.clone()),
(
"anthropic-version".to_string(),
self.anthropic_version.clone(),
),
("content-type".to_string(), "application/json".to_string()),
]
}
fn build_request(&self, config: &AgentConfig) -> Result<Value, AgentError> {
let model = self.resolve_model(&config.model);
let messages = vec![json!({
"role": "user",
"content": config.prompt
})];
let mut body = json!({
"model": model,
"max_tokens": 8192,
"messages": messages
});
if let Some(system) = config.full_system_prompt() {
body["system"] = json!(system);
}
if config.verbose {
body["stream"] = json!(true);
}
let mut output_config = Map::new();
if let Some(ref schema_str) = config.json_schema {
let transformed = transform_schema(schema_str);
let schema_value: Value = serde_json::from_str(&transformed).unwrap_or(json!({}));
if requires_output_config_format(&model) {
output_config.insert(
"format".to_string(),
json!({
"type": "json_schema",
"schema": schema_value
}),
);
} else {
body["tools"] = json!([{
"name": "structured_output",
"description": "Output the result in the requested JSON schema.",
"input_schema": schema_value
}]);
body["tool_choice"] = json!({
"type": "tool",
"name": "structured_output"
});
}
}
if let Some(effort) = config.effort {
output_config.insert("effort".to_string(), json!(<&'static str>::from(effort)));
}
if !output_config.is_empty() {
body["output_config"] = Value::Object(output_config);
}
Ok(body)
}
fn parse_response(&self, body: &Value, config: &AgentConfig) -> Result<TurnResult, AgentError> {
let content = body.get("content").and_then(|c| c.as_array());
let model = body.get("model").and_then(|m| m.as_str()).map(String::from);
let use_output_config_format = config.json_schema.is_some()
&& model.as_deref().is_some_and(requires_output_config_format);
let mut text_parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<HttpToolCall> = Vec::new();
let mut structured_value: Option<Value> = None;
let mut structured_from_tool = false;
if let Some(blocks) = content {
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
text_parts.push(t.to_string());
}
}
"tool_use" => {
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let id = block
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let input = block.get("input").cloned().unwrap_or(json!({}));
if config.json_schema.is_some() && name == "structured_output" {
structured_value = Some(input);
structured_from_tool = true;
} else {
tool_calls.push(HttpToolCall { id, name, input });
}
}
_ => {}
}
}
}
if structured_from_tool {
tool_calls.clear();
}
let stop_reason = body
.get("stop_reason")
.and_then(|s| s.as_str())
.unwrap_or("end_turn");
let is_final =
structured_from_tool || stop_reason == "end_turn" || stop_reason == "max_tokens";
let usage = parse_anthropic_usage(body);
let text = if text_parts.is_empty() {
None
} else {
Some(text_parts.join(""))
};
if use_output_config_format && let Some(ref t) = text {
structured_value = Some(serde_json::from_str(t).unwrap_or(json!({})));
}
Ok(TurnResult {
text,
tool_calls,
is_final,
structured_value,
usage,
model,
})
}
fn parse_sse_line(&self, line: &str) -> Option<SseDelta> {
let data: Value = serde_json::from_str(line).ok()?;
let event_type = data.get("type").and_then(|t| t.as_str())?;
match event_type {
"content_block_delta" => {
let delta = data.get("delta")?;
let delta_type = delta.get("type").and_then(|t| t.as_str())?;
match delta_type {
"text_delta" => {
let text = delta.get("text").and_then(|t| t.as_str())?;
Some(SseDelta::Text(text.to_string()))
}
"input_json_delta" => {
let partial = delta
.get("partial_json")
.and_then(|p| p.as_str())
.unwrap_or("");
let index =
data.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
Some(SseDelta::ToolCallDelta {
index,
id: None,
name: None,
args_fragment: partial.to_string(),
})
}
_ => None,
}
}
"content_block_start" => {
let block = data.get("content_block")?;
let block_type = block.get("type").and_then(|t| t.as_str())?;
if block_type == "tool_use" {
let id = block.get("id").and_then(|i| i.as_str()).map(String::from);
let name = block.get("name").and_then(|n| n.as_str()).map(String::from);
let index = data.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
Some(SseDelta::ToolCallDelta {
index,
id,
name,
args_fragment: String::new(),
})
} else {
None
}
}
"message_delta" => {
let usage = data.get("usage")?;
let output = usage.get("output_tokens").and_then(|v| v.as_u64())?;
Some(SseDelta::Usage {
input_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
output_tokens: output,
})
}
"message_start" => {
let message = data.get("message")?;
let usage = message.get("usage")?;
let input = usage.get("input_tokens").and_then(|v| v.as_u64())?;
let cache_read = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_creation = usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
Some(SseDelta::Usage {
input_tokens: input,
cache_read_input_tokens: cache_read,
cache_creation_input_tokens: cache_creation,
output_tokens: 0,
})
}
"message_stop" => Some(SseDelta::Done),
_ => None,
}
}
fn fold_sse_deltas(
&self,
deltas: Vec<SseDelta>,
config: &AgentConfig,
) -> Result<TurnResult, AgentError> {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_builders: Vec<ToolBuilder> = Vec::new();
let mut input_tokens: u64 = 0;
let mut cache_read_tokens: u64 = 0;
let mut cache_creation_tokens: u64 = 0;
let mut output_tokens: u64 = 0;
for delta in deltas {
match delta {
SseDelta::Text(t) => text_parts.push(t),
SseDelta::ToolCallDelta {
index,
id,
name,
args_fragment,
} => {
while tool_builders.len() <= index {
tool_builders.push(ToolBuilder::default());
}
let builder = &mut tool_builders[index];
if let Some(i) = id {
builder.id = Some(i);
}
if let Some(n) = name {
builder.name = Some(n);
}
builder.args.push_str(&args_fragment);
}
SseDelta::Usage {
input_tokens: i,
cache_read_input_tokens: cr,
cache_creation_input_tokens: cc,
output_tokens: o,
} => {
input_tokens += i;
cache_read_tokens += cr;
cache_creation_tokens += cc;
output_tokens += o;
}
SseDelta::Done | SseDelta::StructuredValue(_) => {}
}
}
let mut structured_value: Option<Value> = None;
let mut tool_calls: Vec<HttpToolCall> = Vec::new();
for builder in tool_builders {
let Some(name) = builder.name else {
continue;
};
let id = builder.id.unwrap_or_default();
let input: Value = serde_json::from_str(&builder.args).unwrap_or(json!({}));
if config.json_schema.is_some() && name == "structured_output" {
structured_value = Some(input);
} else {
tool_calls.push(HttpToolCall { id, name, input });
}
}
let text = if text_parts.is_empty() {
None
} else {
Some(text_parts.join(""))
};
if structured_value.is_some() {
tool_calls.clear();
}
let is_final = tool_calls.is_empty();
Ok(TurnResult {
text,
tool_calls,
is_final,
structured_value,
usage: HttpUsage {
input_tokens: Some(input_tokens),
cache_read_input_tokens: (cache_read_tokens > 0).then_some(cache_read_tokens),
cache_creation_input_tokens: (cache_creation_tokens > 0)
.then_some(cache_creation_tokens),
output_tokens: Some(output_tokens),
},
model: None,
})
}
fn attach_tools(&self, request_body: &mut Value, registry: &ToolRegistry) {
let mut tools: Vec<Value> = request_body
.get("tools")
.and_then(|t| t.as_array())
.cloned()
.unwrap_or_default();
tools.extend(registry.to_anthropic_tools());
request_body["tools"] = Value::Array(tools);
if request_body.get("tool_choice").is_some() {
request_body["tool_choice"] = json!({"type": "any"});
}
}
fn assistant_tool_message(&self, turn: &TurnResult) -> Value {
let mut blocks: Vec<Value> = Vec::new();
if let Some(text) = turn.text.as_deref().filter(|t| !t.is_empty()) {
blocks.push(json!({"type": "text", "text": text}));
}
blocks.extend(turn.tool_calls.iter().map(|tc| {
json!({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": tc.input
})
}));
json!({"role": "assistant", "content": blocks})
}
fn tool_result_messages(
&self,
calls: &[HttpToolCall],
results: &[(String, bool)],
) -> Vec<Value> {
let blocks: Vec<Value> = calls
.iter()
.zip(results)
.map(|(call, (content, is_error))| {
json!({
"type": "tool_result",
"tool_use_id": call.id,
"content": content,
"is_error": is_error
})
})
.collect();
vec![json!({"role": "user", "content": blocks})]
}
fn compute_cost(&self, model: &str, usage: &HttpUsage) -> Option<f64> {
let pricing = StaticPricing::new();
let bd = CostBreakdown::compute_with_cache(
&pricing,
model,
usage.input_tokens.unwrap_or(0),
usage.cache_read_input_tokens.unwrap_or(0),
usage.cache_creation_input_tokens.unwrap_or(0),
usage.output_tokens.unwrap_or(0),
);
Some(bd.total_usd)
}
fn resolve_model(&self, model: &str) -> String {
match model {
m if m == Model::SONNET => AnthropicModel::SONNET_5.to_string(),
m if m == Model::OPUS => AnthropicModel::OPUS_5.to_string(),
m if m == Model::HAIKU => AnthropicModel::HAIKU_4_5.to_string(),
other => other.to_string(),
}
}
}
fn parse_anthropic_usage(body: &Value) -> HttpUsage {
let usage = body.get("usage");
HttpUsage {
input_tokens: usage
.and_then(|u| u.get("input_tokens"))
.and_then(|v| v.as_u64()),
cache_read_input_tokens: usage
.and_then(|u| u.get("cache_read_input_tokens"))
.and_then(|v| v.as_u64()),
cache_creation_input_tokens: usage
.and_then(|u| u.get("cache_creation_input_tokens"))
.and_then(|v| v.as_u64()),
output_tokens: usage
.and_then(|u| u.get("output_tokens"))
.and_then(|v| v.as_u64()),
}
}
#[derive(Default)]
struct ToolBuilder {
id: Option<String>,
name: Option<String>,
args: String,
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::pin::Pin;
use serde_json::json;
use super::*;
use crate::operations::agent::Effort;
use crate::providers::http::tools::{Tool, ToolError, ToolOutput};
fn adapter() -> AnthropicApiAdapter {
AnthropicApiAdapter::new("test-key".to_string())
}
#[test]
fn build_request_basic() {
let a = adapter();
let config = AgentConfig::new("Hello");
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["model"], AnthropicModel::SONNET_5);
assert_eq!(body["max_tokens"], 8192);
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"], "Hello");
assert!(body.get("system").is_none());
assert!(body.get("tools").is_none());
}
#[test]
fn build_request_with_system_prompt() {
let a = adapter();
let config = AgentConfig::new("Hi").system_prompt("Be concise");
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["system"], "Be concise");
}
#[test]
fn build_request_appends_to_the_system_prompt() {
let a = adapter();
let config = AgentConfig::new("Hi")
.system_prompt("Be concise")
.append_system_prompt("Follow REVIEW.md");
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["system"], "Be concise\n\nFollow REVIEW.md");
}
#[test]
fn build_request_with_only_an_appended_prompt() {
let a = adapter();
let config = AgentConfig::new("Hi").append_system_prompt("Follow REVIEW.md");
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["system"], "Follow REVIEW.md");
}
#[test]
fn build_request_with_json_schema_uses_tool_forcing() {
let a = adapter();
let schema = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
let config = AgentConfig::new("Give x").output_schema_raw(schema).into();
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["tools"][0]["name"], "structured_output");
assert_eq!(body["tool_choice"]["type"], "tool");
assert_eq!(body["tool_choice"]["name"], "structured_output");
}
#[test]
fn build_request_sonnet_5_5_uses_output_config_format() {
let a = adapter();
let schema = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
let config = AgentConfig::new("Give x")
.model(AnthropicModel::SONNET_5_5)
.output_schema_raw(schema)
.into();
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["output_config"]["format"]["type"], "json_schema");
assert!(body.get("tools").is_none());
assert!(body.get("tool_choice").is_none());
}
#[test]
fn build_request_effort_sets_output_config_effort() {
let a = adapter();
let config = AgentConfig::new("Hi").effort(Effort::High);
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["output_config"]["effort"], "high");
assert!(body["output_config"].get("format").is_none());
}
#[test]
fn build_request_effort_merges_with_output_config_format() {
let a = adapter();
let schema = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
let config = AgentConfig::new("Give x")
.model(AnthropicModel::SONNET_5_5)
.effort(Effort::Max)
.output_schema_raw(schema)
.into();
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["output_config"]["effort"], "max");
assert_eq!(body["output_config"]["format"]["type"], "json_schema");
}
#[test]
fn build_request_effort_with_tool_forcing_keeps_tools() {
let a = adapter();
let schema = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
let config = AgentConfig::new("Give x")
.model(AnthropicModel::HAIKU_5_5)
.effort(Effort::Low)
.output_schema_raw(schema)
.into();
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["output_config"]["effort"], "low");
assert!(body["output_config"].get("format").is_none());
assert_eq!(body["tool_choice"]["name"], "structured_output");
}
#[test]
fn build_request_without_effort_has_no_output_config() {
let a = adapter();
let body = a
.build_request(&AgentConfig::new("Hi"))
.expect("build_request failed");
assert!(body.get("output_config").is_none());
}
#[test]
fn build_request_haiku_5_5_uses_tool_forcing() {
let a = adapter();
let schema = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
let config = AgentConfig::new("Give x")
.model(AnthropicModel::HAIKU_5_5)
.output_schema_raw(schema)
.into();
let body = a.build_request(&config).expect("build_request failed");
assert_eq!(body["model"], "claude-haiku-5-5");
assert_eq!(body["tool_choice"]["type"], "tool");
assert_eq!(body["tool_choice"]["name"], "structured_output");
assert_eq!(body["tools"][0]["name"], "structured_output");
assert!(body.get("output_config").is_none());
}
#[test]
fn parse_response_text() {
let a = adapter();
let body = json!({
"content": [{"type": "text", "text": "Hello!"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
"model": "claude-sonnet-4-6"
});
let config = AgentConfig::new("Hi");
let result = a.parse_response(&body, &config).expect("parse failed");
assert_eq!(result.text.as_deref(), Some("Hello!"));
assert!(result.is_final);
assert!(result.structured_value.is_none());
assert_eq!(result.usage.input_tokens, Some(10));
assert_eq!(result.usage.output_tokens, Some(5));
}
#[test]
fn parse_response_cache_read_and_creation_usage() {
let a = adapter();
let body = json!({
"content": [{"type": "text", "text": "Hello!"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"cache_read_input_tokens": 2000,
"cache_creation_input_tokens": 300
},
"model": "claude-sonnet-4-6"
});
let config = AgentConfig::new("Hi");
let result = a.parse_response(&body, &config).expect("parse failed");
assert_eq!(result.usage.input_tokens, Some(10));
assert_eq!(result.usage.cache_read_input_tokens, Some(2000));
assert_eq!(result.usage.cache_creation_input_tokens, Some(300));
assert_eq!(result.usage.output_tokens, Some(5));
}
#[test]
fn parse_response_cache_read_absent_is_none() {
let a = adapter();
let body = json!({
"content": [{"type": "text", "text": "Hello!"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let config = AgentConfig::new("Hi");
let result = a.parse_response(&body, &config).expect("parse failed");
assert!(result.usage.cache_read_input_tokens.is_none());
assert!(result.usage.cache_creation_input_tokens.is_none());
}
#[test]
fn parse_sse_message_start_cache_read_usage() {
let a = adapter();
let line = r#"{"type":"message_start","message":{"usage":{"input_tokens":12,"output_tokens":1,"cache_read_input_tokens":4000,"cache_creation_input_tokens":250}}}"#;
let delta = a.parse_sse_line(line).expect("parse_sse failed");
match delta {
SseDelta::Usage {
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
output_tokens,
} => {
assert_eq!(input_tokens, 12);
assert_eq!(cache_read_input_tokens, 4000);
assert_eq!(cache_creation_input_tokens, 250);
assert_eq!(output_tokens, 0);
}
other => panic!("expected usage delta, got {other:?}"),
}
}
#[test]
fn fold_sse_deltas_cache_read_summed() {
let a = adapter();
let deltas = vec![
SseDelta::Usage {
input_tokens: 12,
cache_read_input_tokens: 4000,
cache_creation_input_tokens: 250,
output_tokens: 0,
},
SseDelta::Text("ok".to_string()),
SseDelta::Usage {
input_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
output_tokens: 30,
},
SseDelta::Done,
];
let config = AgentConfig::new("Hi");
let result = a.fold_sse_deltas(deltas, &config).expect("fold failed");
assert_eq!(result.usage.input_tokens, Some(12));
assert_eq!(result.usage.cache_read_input_tokens, Some(4000));
assert_eq!(result.usage.cache_creation_input_tokens, Some(250));
assert_eq!(result.usage.output_tokens, Some(30));
}
#[test]
fn fold_sse_deltas_cache_read_zero_is_none() {
let a = adapter();
let deltas = vec![SseDelta::Usage {
input_tokens: 12,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
output_tokens: 3,
}];
let config = AgentConfig::new("Hi");
let result = a.fold_sse_deltas(deltas, &config).expect("fold failed");
assert!(result.usage.cache_read_input_tokens.is_none());
assert!(result.usage.cache_creation_input_tokens.is_none());
}
#[test]
fn compute_cost_cache_read_cheaper_than_input() {
let a = adapter();
let cache_only = HttpUsage {
input_tokens: Some(0),
cache_read_input_tokens: Some(1_000_000),
cache_creation_input_tokens: None,
output_tokens: Some(0),
};
let uncached = HttpUsage {
input_tokens: Some(1_000_000),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
output_tokens: Some(0),
};
let cache_cost = a
.compute_cost("claude-sonnet-4-6", &cache_only)
.expect("cost");
let input_cost = a
.compute_cost("claude-sonnet-4-6", &uncached)
.expect("cost");
assert!((cache_cost - 0.3).abs() < 1e-9);
assert!((input_cost - 3.0).abs() < 1e-9);
}
#[test]
fn parse_response_structured_output() {
let a = adapter();
let body = json!({
"content": [{"type": "tool_use", "id": "tu_1", "name": "structured_output", "input": {"x": 42}}],
"stop_reason": "tool_use",
"usage": {"input_tokens": 15, "output_tokens": 10},
"model": "claude-sonnet-4-6"
});
let schema = r#"{"type":"object"}"#;
let config = AgentConfig::new("Give x").output_schema_raw(schema).into();
let result = a.parse_response(&body, &config).expect("parse failed");
assert_eq!(result.structured_value, Some(json!({"x": 42})));
assert!(result.tool_calls.is_empty());
}
#[test]
fn parse_response_output_config_format_reads_text_json() {
let a = adapter();
let body = json!({
"content": [{"type": "text", "text": "{\"x\":42}"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 15, "output_tokens": 10},
"model": "claude-sonnet-5-5"
});
let schema = r#"{"type":"object"}"#;
let config = AgentConfig::new("Give x").output_schema_raw(schema).into();
let result = a.parse_response(&body, &config).expect("parse failed");
assert_eq!(result.structured_value, Some(json!({"x": 42})));
assert_eq!(result.text, Some("{\"x\":42}".to_string()));
}
#[test]
fn parse_sse_line_text_delta() {
let a = adapter();
let line =
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#;
let delta = a.parse_sse_line(line).expect("parse_sse failed");
assert!(matches!(delta, SseDelta::Text(t) if t == "Hi"));
}
#[test]
fn parse_sse_line_message_stop() {
let a = adapter();
let line = r#"{"type":"message_stop"}"#;
let delta = a.parse_sse_line(line).expect("parse_sse failed");
assert!(matches!(delta, SseDelta::Done));
}
#[test]
fn resolve_model_aliases() {
let a = adapter();
assert_eq!(a.resolve_model("sonnet"), AnthropicModel::SONNET_5);
assert_eq!(a.resolve_model("opus"), AnthropicModel::OPUS_5);
assert_eq!(a.resolve_model("haiku"), AnthropicModel::HAIKU_4_5);
assert_eq!(
a.resolve_model("claude-haiku-5-5"),
AnthropicModel::HAIKU_5_5
);
assert_eq!(a.resolve_model("claude-opus-4-6"), "claude-opus-4-6");
assert_eq!(a.resolve_model("claude-opus-4-8"), "claude-opus-4-8");
assert_eq!(a.resolve_model("claude-opus-5"), "claude-opus-5");
assert_eq!(a.resolve_model("claude-opus-5-5"), AnthropicModel::OPUS_5_5);
assert_eq!(
a.resolve_model("claude-sonnet-5-5"),
AnthropicModel::SONNET_5_5
);
assert_eq!(
a.resolve_model("claude-mythos-5-1"),
AnthropicModel::MYTHOS_5_1
);
assert_eq!(a.resolve_model("claude-fable-5"), "claude-fable-5");
}
#[test]
fn auth_headers_format() {
let a = adapter();
let headers = a.auth_headers();
assert!(
headers
.iter()
.any(|(k, v)| k == "x-api-key" && v == "test-key")
);
assert!(
headers
.iter()
.any(|(k, v)| k == "anthropic-version" && v == "2023-06-01")
);
}
struct LookupTool {
name: &'static str,
}
impl Tool for LookupTool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"Looks something up"
}
fn parameters_schema(&self) -> Value {
json!({"type": "object", "properties": {"q": {"type": "string"}}})
}
fn execute(
&self,
_input: Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
Box::pin(async { Ok(ToolOutput::success("found")) })
}
}
fn registry() -> ToolRegistry {
ToolRegistry::new()
.register(LookupTool { name: "lookup" })
.register(LookupTool { name: "search" })
}
const X_SCHEMA: &str = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
fn tool_names(body: &Value) -> Vec<&str> {
body["tools"]
.as_array()
.expect("tools array")
.iter()
.filter_map(|t| t["name"].as_str())
.collect()
}
#[test]
fn attach_tools_keeps_structured_output_next_to_registry_tools() {
let a = adapter();
let config = AgentConfig::new("Give x")
.model(AnthropicModel::HAIKU_5_5)
.output_schema_raw(X_SCHEMA)
.into();
let mut body = a.build_request(&config).expect("build_request failed");
a.attach_tools(&mut body, ®istry());
assert_eq!(
tool_names(&body),
vec!["structured_output", "lookup", "search"]
);
assert!(body["tools"][1]["input_schema"].is_object());
assert_eq!(body["tool_choice"], json!({"type": "any"}));
if let Some(name) = body["tool_choice"].get("name").and_then(|n| n.as_str()) {
assert!(tool_names(&body).contains(&name));
}
}
#[test]
fn attach_tools_without_schema_sets_no_tool_choice() {
let a = adapter();
let mut body = a
.build_request(&AgentConfig::new("Hi"))
.expect("build_request failed");
a.attach_tools(&mut body, ®istry());
assert_eq!(tool_names(&body), vec!["lookup", "search"]);
assert!(body.get("tool_choice").is_none());
}
#[test]
fn attach_tools_on_output_config_format_model_adds_only_registry_tools() {
let a = adapter();
let config = AgentConfig::new("Give x")
.model(AnthropicModel::SONNET_5_5)
.output_schema_raw(X_SCHEMA)
.into();
let mut body = a.build_request(&config).expect("build_request failed");
a.attach_tools(&mut body, ®istry());
assert_eq!(tool_names(&body), vec!["lookup", "search"]);
assert!(body.get("tool_choice").is_none());
assert_eq!(body["output_config"]["format"]["type"], "json_schema");
}
fn turn(text: Option<&str>, calls: Vec<HttpToolCall>) -> TurnResult {
TurnResult {
text: text.map(String::from),
tool_calls: calls,
is_final: false,
structured_value: None,
usage: HttpUsage::default(),
model: None,
}
}
fn call(id: &str, name: &str) -> HttpToolCall {
HttpToolCall {
id: id.to_string(),
name: name.to_string(),
input: json!({"q": "a"}),
}
}
#[test]
fn assistant_tool_message_uses_tool_use_blocks() {
let a = adapter();
let msg = a.assistant_tool_message(&turn(
Some("thinking"),
vec![call("tu_1", "lookup"), call("tu_2", "search")],
));
assert_eq!(msg["role"], "assistant");
assert_eq!(
msg["content"][0],
json!({"type": "text", "text": "thinking"})
);
assert_eq!(msg["content"][1]["type"], "tool_use");
assert_eq!(msg["content"][1]["id"], "tu_1");
assert_eq!(msg["content"][1]["input"], json!({"q": "a"}));
assert_eq!(msg["content"][2]["name"], "search");
let no_text = a.assistant_tool_message(&turn(Some(""), vec![call("tu_1", "lookup")]));
assert_eq!(no_text["content"].as_array().map(Vec::len), Some(1));
assert_eq!(no_text["content"][0]["type"], "tool_use");
}
#[test]
fn tool_result_messages_group_results_in_one_user_message() {
let a = adapter();
let calls = vec![call("tu_1", "lookup"), call("tu_2", "search")];
let results = vec![("found".to_string(), false), ("boom".to_string(), true)];
let messages = a.tool_result_messages(&calls, &results);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let blocks = messages[0]["content"].as_array().expect("blocks");
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0]["type"], "tool_result");
assert_eq!(blocks[0]["tool_use_id"], "tu_1");
assert_eq!(blocks[0]["content"], "found");
assert_eq!(blocks[0]["is_error"], false);
assert_eq!(blocks[1]["tool_use_id"], "tu_2");
assert_eq!(blocks[1]["is_error"], true);
}
#[test]
fn parse_response_structured_output_with_registry_call_is_final() {
let a = adapter();
let body = json!({
"content": [
{"type": "tool_use", "id": "tu_1", "name": "lookup", "input": {"q": "a"}},
{"type": "tool_use", "id": "tu_2", "name": "structured_output", "input": {"x": 1}},
{"type": "tool_use", "id": "tu_3", "name": "search", "input": {"q": "b"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 1, "output_tokens": 1},
"model": "claude-haiku-5-5"
});
let config = AgentConfig::new("Give x")
.output_schema_raw(X_SCHEMA)
.into();
let result = a.parse_response(&body, &config).expect("parse failed");
assert!(result.is_final);
assert!(result.tool_calls.is_empty());
assert_eq!(result.structured_value, Some(json!({"x": 1})));
}
#[test]
fn fold_sse_deltas_skips_nameless_text_block_builder() {
let a = adapter();
let deltas = vec![
SseDelta::Text("let me check".to_string()),
SseDelta::ToolCallDelta {
index: 1,
id: Some("tu_1".to_string()),
name: Some("lookup".to_string()),
args_fragment: String::new(),
},
SseDelta::ToolCallDelta {
index: 1,
id: None,
name: None,
args_fragment: r#"{"q":"a"}"#.to_string(),
},
SseDelta::Done,
];
let result = a
.fold_sse_deltas(deltas, &AgentConfig::new("Hi"))
.expect("fold failed");
assert_eq!(result.tool_calls.len(), 1);
assert_eq!(result.tool_calls[0].name, "lookup");
assert_eq!(result.tool_calls[0].input, json!({"q": "a"}));
assert!(!result.is_final);
}
#[test]
fn fold_sse_deltas_structured_output_with_registry_call_is_final() {
let a = adapter();
let deltas = vec![
SseDelta::ToolCallDelta {
index: 0,
id: Some("tu_1".to_string()),
name: Some("lookup".to_string()),
args_fragment: r#"{"q":"a"}"#.to_string(),
},
SseDelta::ToolCallDelta {
index: 1,
id: Some("tu_2".to_string()),
name: Some("structured_output".to_string()),
args_fragment: String::new(),
},
SseDelta::ToolCallDelta {
index: 1,
id: None,
name: None,
args_fragment: r#"{"x":1}"#.to_string(),
},
SseDelta::ToolCallDelta {
index: 2,
id: Some("tu_3".to_string()),
name: Some("search".to_string()),
args_fragment: r#"{"q":"b"}"#.to_string(),
},
SseDelta::Done,
];
let config = AgentConfig::new("Give x")
.output_schema_raw(X_SCHEMA)
.into();
let result = a.fold_sse_deltas(deltas, &config).expect("fold failed");
assert!(result.is_final);
assert!(result.tool_calls.is_empty());
assert_eq!(result.structured_value, Some(json!({"x": 1})));
}
#[test]
fn endpoint_url_uses_base_url() {
let a = adapter().with_base_url("http://127.0.0.1:9".to_string());
assert_eq!(a.endpoint_url("any"), "http://127.0.0.1:9/v1/messages");
}
#[test]
fn endpoint_url_constant() {
let a = adapter();
assert_eq!(
a.endpoint_url("any"),
"https://api.anthropic.com/v1/messages"
);
}
}