use super::cache::cache_min_tokens;
use super::request::split_messages_structured;
use super::types::{
ApiMessage, ApiResponse, ApiUsage, CACHE_MARKER_STABLE, CACHE_MARKER_TOOLS,
CACHE_MARKER_VOLATILE, CacheControl, CacheType, ContentBlock, ImageSource, StructuredContent,
ThinkingParam,
};
use super::*;
use crate::provider::{ImageData, MessageMetadata, Role, ThinkingBlock};
use tokio_stream::StreamExt;
#[test]
fn context_window_known_models() {
let sonnet = ClaudeProvider::new("k".into(), "claude-sonnet-4-5-20250929".into(), 1024);
assert_eq!(sonnet.context_window(), Some(200_000));
let opus = ClaudeProvider::new("k".into(), "claude-opus-4-6".into(), 1024);
assert_eq!(opus.context_window(), Some(200_000));
let haiku = ClaudeProvider::new("k".into(), "claude-haiku-4-5".into(), 1024);
assert_eq!(haiku.context_window(), Some(200_000));
}
#[test]
fn context_window_unknown_model() {
let provider = ClaudeProvider::new("k".into(), "unknown-model".into(), 1024);
assert!(provider.context_window().is_none());
}
#[test]
fn split_messages_extracts_system() {
let messages = vec![
Message {
role: Role::System,
content: "You are helpful.".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: "Hi".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let (system, chat) = split_messages(&messages);
assert_eq!(system.unwrap(), "You are helpful.");
assert_eq!(chat.len(), 1);
assert_eq!(chat[0].role, "user");
}
#[test]
fn split_messages_no_system() {
let messages = vec![Message {
role: Role::User,
content: "Hi".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let (system, chat) = split_messages(&messages);
assert!(system.is_none());
assert_eq!(chat.len(), 1);
}
#[test]
fn split_messages_multiple_system() {
let messages = vec![
Message {
role: Role::System,
content: "Part 1".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::System,
content: "Part 2".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: "Hi".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let (system, _) = split_messages(&messages);
assert_eq!(system.unwrap(), "Part 1\n\nPart 2");
}
#[test]
fn supports_streaming_returns_true() {
let provider =
ClaudeProvider::new("test-key".into(), "claude-sonnet-4-5-20250929".into(), 1024);
assert!(provider.supports_streaming());
}
#[test]
fn debug_redacts_api_key() {
let provider = ClaudeProvider::new(
"sk-secret-key".into(),
"claude-sonnet-4-5-20250929".into(),
1024,
);
let debug_output = format!("{provider:?}");
assert!(!debug_output.contains("sk-secret-key"));
assert!(debug_output.contains("<redacted>"));
assert!(debug_output.contains("claude-sonnet-4-5-20250929"));
}
#[test]
fn claude_supports_embeddings_returns_false() {
let provider =
ClaudeProvider::new("test-key".into(), "claude-sonnet-4-5-20250929".into(), 1024);
assert!(!provider.supports_embeddings());
}
#[tokio::test]
async fn claude_embed_returns_error() {
let provider =
ClaudeProvider::new("test-key".into(), "claude-sonnet-4-5-20250929".into(), 1024);
let result = provider.embed("test").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("embedding not supported by claude")
);
}
#[test]
fn name_returns_claude() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-5-20250929".into(), 1024);
assert_eq!(provider.name(), "claude");
}
#[test]
fn clone_preserves_fields() {
let provider = ClaudeProvider::new(
"test-api-key".into(),
"claude-sonnet-4-5-20250929".into(),
2048,
);
let cloned = provider.clone();
assert_eq!(cloned.model, provider.model);
assert_eq!(cloned.api_key, provider.api_key);
assert_eq!(cloned.max_tokens, provider.max_tokens);
}
#[test]
fn new_stores_fields_correctly() {
let provider = ClaudeProvider::new("my-key".into(), "claude-haiku-35".into(), 4096);
assert_eq!(provider.api_key, "my-key");
assert_eq!(provider.model, "claude-haiku-35");
assert_eq!(provider.max_tokens, 4096);
}
#[test]
fn debug_includes_model_and_max_tokens() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-5-20250929".into(), 512);
let debug = format!("{provider:?}");
assert!(debug.contains("ClaudeProvider"));
assert!(debug.contains("512"));
assert!(debug.contains("<reqwest::Client>"));
}
#[test]
fn request_body_serializes_without_system() {
let body = RequestBody {
model: "claude-sonnet-4-5-20250929",
max_tokens: 1024,
system: None,
messages: &[ApiMessage {
role: "user",
content: "hello",
}],
stream: false,
thinking: None,
output_config: None,
temperature: None,
context_management: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(!json.contains("system"));
assert!(!json.contains("stream"));
assert!(json.contains("\"model\":\"claude-sonnet-4-5-20250929\""));
assert!(json.contains("\"max_tokens\":1024"));
}
#[test]
fn request_body_serializes_with_system_blocks() {
let body = RequestBody {
model: "claude-sonnet-4-5-20250929",
max_tokens: 1024,
system: Some(vec![SystemContentBlock {
block_type: "text",
text: "You are helpful.".into(),
cache_control: Some(CacheControl {
cache_type: CacheType::Ephemeral,
}),
}]),
messages: &[],
stream: false,
thinking: None,
output_config: None,
temperature: None,
context_management: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"system\""));
assert!(json.contains("You are helpful."));
assert!(json.contains("\"cache_control\""));
}
#[test]
fn request_body_serializes_stream_true() {
let body = RequestBody {
model: "test",
max_tokens: 100,
system: None,
messages: &[],
stream: true,
thinking: None,
output_config: None,
temperature: None,
context_management: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"stream\":true"));
}
#[test]
fn split_messages_all_roles() {
let messages = vec![
Message {
role: Role::System,
content: "system prompt".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: "user msg".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "assistant reply".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: "followup".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let (system, chat) = split_messages(&messages);
assert_eq!(system.unwrap(), "system prompt");
assert_eq!(chat.len(), 3);
assert_eq!(chat[0].role, "user");
assert_eq!(chat[0].content, "user msg");
assert_eq!(chat[1].role, "assistant");
assert_eq!(chat[1].content, "assistant reply");
assert_eq!(chat[2].role, "user");
assert_eq!(chat[2].content, "followup");
}
#[test]
fn split_messages_empty() {
let (system, chat) = split_messages(&[]);
assert!(system.is_none());
assert!(chat.is_empty());
}
#[test]
fn api_message_serializes() {
let msg = ApiMessage {
role: "user",
content: "hello world",
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"content\":\"hello world\""));
}
#[test]
fn content_block_deserializes() {
let json = r#"{"text":"response text"}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert_eq!(block.text, "response text");
}
#[test]
fn api_response_multiple_content_blocks() {
let json = r#"{"content":[{"text":"first"},{"text":"second"}]}"#;
let resp: ApiResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.content.len(), 2);
assert_eq!(resp.content[0].text, "first");
assert_eq!(resp.content[1].text, "second");
}
#[tokio::test]
async fn chat_with_unreachable_endpoint_errors() {
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let messages = vec![Message {
role: Role::User,
content: "test".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let result = provider.chat(&messages).await;
assert!(result.is_err());
}
#[tokio::test]
async fn chat_stream_with_unreachable_endpoint_errors() {
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let messages = vec![Message {
role: Role::User,
content: "test".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let result = provider.chat_stream(&messages).await;
assert!(result.is_err());
}
#[test]
fn split_messages_only_system() {
let messages = vec![Message {
role: Role::System,
content: "instruction".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let (system, chat) = split_messages(&messages);
assert_eq!(system.unwrap(), "instruction");
assert!(chat.is_empty());
}
#[test]
fn split_messages_only_assistant() {
let messages = vec![Message {
role: Role::Assistant,
content: "reply".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let (system, chat) = split_messages(&messages);
assert!(system.is_none());
assert_eq!(chat.len(), 1);
assert_eq!(chat[0].role, "assistant");
}
#[test]
fn split_messages_interleaved_system() {
let messages = vec![
Message {
role: Role::System,
content: "first".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: "question".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::System,
content: "second".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let (system, chat) = split_messages(&messages);
assert_eq!(system.unwrap(), "first\n\nsecond");
assert_eq!(chat.len(), 1);
}
#[test]
fn request_body_serializes_with_stream_false_omits_stream() {
let body = RequestBody {
model: "test",
max_tokens: 100,
system: None,
messages: &[],
stream: false,
thinking: None,
output_config: None,
temperature: None,
context_management: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(!json.contains("stream"));
}
#[test]
fn split_system_no_markers_caches_entire_block() {
let long_text = format!("You are Zeph, an AI assistant. {}", "x".repeat(8200));
let blocks = split_system_into_blocks(&long_text, "claude-sonnet-4-6");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].cache_control.is_some());
assert!(blocks[0].text.contains("Zeph"));
}
#[test]
fn split_system_no_markers_short_text_skips_cache() {
let blocks = split_system_into_blocks("You are Zeph, an AI assistant.", "claude-sonnet-4-6");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].cache_control.is_none());
}
#[test]
fn split_system_no_markers_exact_threshold_sonnet_caches() {
let exact_text = "A".repeat(8192);
let blocks = split_system_into_blocks(&exact_text, "claude-sonnet-4-6");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].cache_control.is_some());
}
#[test]
fn split_system_no_markers_opus_skips_short_text() {
let medium_text = "A".repeat(8192);
let blocks = split_system_into_blocks(&medium_text, "claude-opus-4-6");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].cache_control.is_none());
}
#[test]
fn split_system_no_markers_opus_caches_long_text() {
let long_text = "A".repeat(16384);
let blocks = split_system_into_blocks(&long_text, "claude-opus-4-6");
assert_eq!(blocks.len(), 1);
assert!(blocks[0].cache_control.is_some());
}
#[test]
fn split_system_with_all_markers() {
let padding = "x".repeat(8200);
let system = format!(
"base prompt {padding}\n{CACHE_MARKER_STABLE}\nskills here {padding}\n\
{CACHE_MARKER_TOOLS}\ntool catalog {padding}\n\
{CACHE_MARKER_VOLATILE}\nvolatile stuff"
);
let blocks = split_system_into_blocks(&system, "claude-sonnet-4-6");
assert_eq!(blocks.len(), 4);
assert!(blocks[0].cache_control.is_some());
assert!(blocks[0].text.contains("base prompt"));
assert!(blocks[1].cache_control.is_some());
assert!(blocks[1].text.contains("skills here"));
assert!(blocks[2].cache_control.is_some());
assert!(blocks[2].text.contains("tool catalog"));
assert!(blocks[3].cache_control.is_none());
assert!(blocks[3].text.contains("volatile stuff"));
}
#[test]
fn split_system_partial_markers() {
let padding = "x".repeat(8200);
let system = format!("base prompt {padding}\n{CACHE_MARKER_VOLATILE}\nvolatile only");
let blocks = split_system_into_blocks(&system, "claude-sonnet-4-6");
assert_eq!(blocks.len(), 2);
assert!(blocks[0].cache_control.is_some());
assert!(blocks[1].cache_control.is_none());
}
#[test]
fn split_system_block1_padded_when_below_threshold() {
let system = format!("short text\n{CACHE_MARKER_STABLE}\nmore content");
let blocks = split_system_into_blocks(&system, "claude-sonnet-4-6");
assert!(blocks[0].cache_control.is_some());
assert!(blocks[0].text.contains("short text"));
assert!(blocks[0].text.contains("Agent Identity"));
}
#[test]
fn split_system_block2_not_padded_when_below_threshold() {
let padding = "x".repeat(8200);
let system =
format!("base {padding}\n{CACHE_MARKER_STABLE}\nshort\n{CACHE_MARKER_TOOLS}\nmore");
let blocks = split_system_into_blocks(&system, "claude-sonnet-4-6");
assert!(!blocks[1].text.contains("Agent Identity"));
}
#[test]
fn api_usage_deserialization() {
let json = r#"{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":1000,"cache_read_input_tokens":900}"#;
let usage: ApiUsage = serde_json::from_str(json).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_creation_input_tokens, 1000);
assert_eq!(usage.cache_read_input_tokens, 900);
}
#[test]
fn api_response_with_usage() {
let json = r#"{"content":[{"text":"Hello"}],"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}"#;
let resp: ApiResponse = serde_json::from_str(json).unwrap();
assert!(resp.usage.is_some());
assert_eq!(resp.usage.unwrap().input_tokens, 10);
}
#[test]
fn api_response_deserializes() {
let json = r#"{"content":[{"text":"Hello world"}]}"#;
let resp: ApiResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.content.len(), 1);
assert_eq!(resp.content[0].text, "Hello world");
}
#[test]
fn api_response_empty_content() {
let json = r#"{"content":[]}"#;
let resp: ApiResponse = serde_json::from_str(json).unwrap();
assert!(resp.content.is_empty());
}
#[tokio::test]
#[ignore = "requires ZEPH_CLAUDE_API_KEY env var"]
async fn integration_claude_chat() {
let api_key = std::env::var("ZEPH_CLAUDE_API_KEY").expect("ZEPH_CLAUDE_API_KEY must be set");
let provider = ClaudeProvider::new(api_key, "claude-sonnet-4-5-20250929".into(), 256);
let messages = vec![Message {
role: Role::User,
content: "Reply with exactly: pong".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let response = provider.chat(&messages).await.unwrap();
assert!(response.to_lowercase().contains("pong"));
}
#[tokio::test]
#[ignore = "requires ZEPH_CLAUDE_API_KEY env var"]
async fn integration_claude_chat_stream() {
let api_key = std::env::var("ZEPH_CLAUDE_API_KEY").expect("ZEPH_CLAUDE_API_KEY must be set");
let provider = ClaudeProvider::new(api_key, "claude-sonnet-4-5-20250929".into(), 256);
let messages = vec![Message {
role: Role::User,
content: "Reply with exactly: pong".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let mut stream = provider.chat_stream(&messages).await.unwrap();
let mut full_response = String::new();
let mut chunk_count = 0;
while let Some(result) = stream.next().await {
if let crate::StreamChunk::Content(text) = result.unwrap() {
full_response.push_str(&text);
}
chunk_count += 1;
}
assert!(!full_response.is_empty());
assert!(full_response.to_lowercase().contains("pong"));
assert!(chunk_count >= 1);
}
#[tokio::test]
#[ignore = "requires ZEPH_CLAUDE_API_KEY env var"]
async fn integration_claude_stream_matches_chat() {
let api_key = std::env::var("ZEPH_CLAUDE_API_KEY").expect("ZEPH_CLAUDE_API_KEY must be set");
let provider = ClaudeProvider::new(api_key, "claude-sonnet-4-5-20250929".into(), 256);
let messages = vec![Message {
role: Role::User,
content: "What is 2+2? Reply with just the number.".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let chat_response = provider.chat(&messages).await.unwrap();
let mut stream = provider.chat_stream(&messages).await.unwrap();
let mut stream_response = String::new();
while let Some(result) = stream.next().await {
if let crate::StreamChunk::Content(text) = result.unwrap() {
stream_response.push_str(&text);
}
}
assert!(chat_response.contains('4'));
assert!(stream_response.contains('4'));
}
#[test]
fn anthropic_tool_serialization() {
let tool = AnthropicTool {
name: "bash",
description: "Execute a shell command",
input_schema: &serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"]
}),
};
let json = serde_json::to_string(&tool).unwrap();
assert!(json.contains("\"name\":\"bash\""));
assert!(json.contains("\"input_schema\""));
}
#[test]
fn parse_tool_response_text_only() {
let resp = ToolApiResponse {
content: vec![AnthropicContentBlock::Text {
text: "Hello".into(),
cache_control: None,
}],
stop_reason: None,
usage: None,
};
let (result, compaction) = parse_tool_response(resp);
assert!(matches!(result, ChatResponse::Text(s) if s == "Hello"));
assert!(compaction.is_none());
}
#[test]
fn parse_tool_response_with_tool_use() {
let resp = ToolApiResponse {
content: vec![
AnthropicContentBlock::Text {
text: "I'll run that".into(),
cache_control: None,
},
AnthropicContentBlock::ToolUse {
id: "toolu_123".into(),
name: "bash".into(),
input: serde_json::json!({"command": "ls"}),
},
],
stop_reason: None,
usage: None,
};
let (result, compaction) = parse_tool_response(resp);
if let ChatResponse::ToolUse {
text, tool_calls, ..
} = result
{
assert_eq!(text.unwrap(), "I'll run that");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].name, "bash");
assert_eq!(tool_calls[0].id, "toolu_123");
} else {
panic!("expected ToolUse");
}
assert!(compaction.is_none());
}
#[test]
fn parse_tool_response_tool_use_only() {
let resp = ToolApiResponse {
content: vec![AnthropicContentBlock::ToolUse {
id: "toolu_456".into(),
name: "read".into(),
input: serde_json::json!({"path": "/tmp/file.txt"}),
}],
stop_reason: None,
usage: None,
};
let (result, compaction) = parse_tool_response(resp);
if let ChatResponse::ToolUse {
text, tool_calls, ..
} = result
{
assert!(text.is_none());
assert_eq!(tool_calls.len(), 1);
} else {
panic!("expected ToolUse");
}
assert!(compaction.is_none());
}
#[test]
fn parse_tool_response_json_deserialization() {
let json = r#"{"content":[{"type":"text","text":"Let me check"},{"type":"tool_use","id":"toolu_abc","name":"bash","input":{"command":"ls"}}]}"#;
let resp: ToolApiResponse = serde_json::from_str(json).unwrap();
let (result, _) = parse_tool_response(resp);
assert!(matches!(result, ChatResponse::ToolUse { .. }));
}
#[test]
fn parse_tool_response_with_compaction() {
let resp = ToolApiResponse {
content: vec![AnthropicContentBlock::Compaction {
summary: "Context was summarized for efficiency.".into(),
}],
stop_reason: None,
usage: None,
};
let (result, compaction) = parse_tool_response(resp);
assert!(matches!(result, ChatResponse::Text(ref s) if s.is_empty()));
assert_eq!(
compaction.as_deref(),
Some("Context was summarized for efficiency.")
);
}
#[test]
fn split_messages_structured_with_tool_parts() {
let messages = vec![
Message::from_parts(
Role::Assistant,
vec![
MessagePart::Text {
text: "I'll run that".into(),
},
MessagePart::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: serde_json::json!({"command": "ls"}),
},
],
),
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t1".into(),
content: "file1.rs".into(),
is_error: false,
}],
),
];
let (system, chat) = split_messages_structured(&messages, true);
assert!(system.is_none());
assert_eq!(chat.len(), 2);
let assistant_json = serde_json::to_string(&chat[0]).unwrap();
assert!(assistant_json.contains("tool_use"));
assert!(assistant_json.contains("\"id\":\"t1\""));
let user_json = serde_json::to_string(&chat[1]).unwrap();
assert!(user_json.contains("tool_result"));
assert!(user_json.contains("\"tool_use_id\":\"t1\""));
}
#[test]
fn split_messages_structured_downgrades_unmatched_tool_use_to_text() {
let messages = vec![
Message::from_parts(
Role::Assistant,
vec![
MessagePart::Text {
text: "Let me run this.".into(),
},
MessagePart::ToolUse {
id: "orphan_id".into(),
name: "shell".into(),
input: serde_json::json!({"command": "ls"}),
},
],
),
Message::from_parts(
Role::User,
vec![MessagePart::Text {
text: "Thanks, what did you find?".into(),
}],
),
];
let (_, chat) = split_messages_structured(&messages, false);
assert_eq!(chat.len(), 2);
let assistant_json = serde_json::to_string(&chat[0]).unwrap();
assert!(
!assistant_json.contains("\"type\":\"tool_use\""),
"unmatched tool_use must be downgraded: {assistant_json}"
);
assert!(
assistant_json.contains("orphan_id") || assistant_json.contains("shell"),
"downgraded tool_use must appear as text fallback: {assistant_json}"
);
}
#[test]
fn split_messages_structured_preserves_matched_tool_use_block() {
let messages = vec![
Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: "matched_id".into(),
name: "bash".into(),
input: serde_json::json!({"command": "echo hi"}),
}],
),
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "matched_id".into(),
content: "hi".into(),
is_error: false,
}],
),
];
let (_, chat) = split_messages_structured(&messages, false);
assert_eq!(chat.len(), 2);
let assistant_json = serde_json::to_string(&chat[0]).unwrap();
assert!(
assistant_json.contains("\"type\":\"tool_use\""),
"matched tool_use must be emitted as tool_use block: {assistant_json}"
);
assert!(assistant_json.contains("\"id\":\"matched_id\""));
}
#[test]
fn split_structured_downgrades_orphaned_tool_result() {
let messages = vec![
Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: "t_orphan".into(),
name: "memory_save".into(),
input: serde_json::json!({"content": "x"}),
}],
),
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t_orphan".into(),
content: "saved".into(),
is_error: false,
}],
),
];
let (_, chat) = split_messages_structured(&messages, false);
assert_eq!(chat.len(), 2);
let assistant_json = serde_json::to_string(&chat[0]).unwrap();
assert!(
assistant_json.contains("\"type\":\"tool_use\""),
"matched tool_use must be emitted as native block: {assistant_json}"
);
let user_json = serde_json::to_string(&chat[1]).unwrap();
assert!(
user_json.contains("\"type\":\"tool_result\""),
"matched tool_result must be emitted as native block: {user_json}"
);
let messages_partial = vec![
Message::from_parts(
Role::Assistant,
vec![
MessagePart::ToolUse {
id: "t_matched".into(),
name: "shell".into(),
input: serde_json::json!({"command": "ls"}),
},
MessagePart::ToolUse {
id: "t_missing_result".into(),
name: "shell".into(),
input: serde_json::json!({"command": "pwd"}),
},
],
),
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t_matched".into(),
content: "output".into(),
is_error: false,
}],
),
];
let (_, chat2) = split_messages_structured(&messages_partial, false);
assert_eq!(chat2.len(), 2);
let assistant_json2 = serde_json::to_string(&chat2[0]).unwrap();
let has_native_missing = assistant_json2.contains("\"type\":\"tool_use\"")
&& assistant_json2.contains("\"id\":\"t_missing_result\"");
assert!(
!has_native_missing,
"t_missing_result must not appear as a native tool_use block: {assistant_json2}"
);
assert!(
assistant_json2.contains("\"id\":\"t_matched\""),
"t_matched must be emitted as native tool_use: {assistant_json2}"
);
let user_json2 = serde_json::to_string(&chat2[1]).unwrap();
assert!(
user_json2.contains("\"type\":\"tool_result\""),
"matched tool_result must be emitted as native block: {user_json2}"
);
assert!(
user_json2.contains("\"tool_use_id\":\"t_matched\""),
"t_matched tool_result must be present: {user_json2}"
);
}
#[test]
fn split_structured_system_not_in_visible() {
let messages = vec![
Message {
role: Role::System,
content: "You are a helpful assistant.".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: "t_sys_test".into(),
name: "bash".into(),
input: serde_json::json!({"command": "echo hi"}),
}],
),
Message {
role: Role::System,
content: "Additional context injected mid-conversation.".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "t_sys_test".into(),
content: "hi".into(),
is_error: false,
}],
),
];
let (system_text, chat) = split_messages_structured(&messages, false);
let system = system_text.unwrap_or_default();
assert!(
system.contains("You are a helpful assistant."),
"first system message must be in system text: {system}"
);
assert!(
system.contains("Additional context"),
"interleaved system message must be in system text: {system}"
);
assert_eq!(
chat.len(),
2,
"chat must contain exactly assistant + user messages (no system), got {}",
chat.len()
);
assert_eq!(chat[0].role, "assistant");
assert_eq!(chat[1].role, "user");
let assistant_json = serde_json::to_string(&chat[0]).unwrap();
assert!(
assistant_json.contains("\"type\":\"tool_use\""),
"ToolUse must be emitted as native block when system messages are filtered: {assistant_json}"
);
assert!(
assistant_json.contains("\"id\":\"t_sys_test\""),
"correct tool_use id must be present: {assistant_json}"
);
let user_json = serde_json::to_string(&chat[1]).unwrap();
assert!(
user_json.contains("\"type\":\"tool_result\""),
"ToolResult must be emitted as native block: {user_json}"
);
}
#[test]
fn supports_tool_use_returns_true() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-5-20250929".into(), 1024);
assert!(provider.supports_tool_use());
}
#[test]
fn anthropic_content_block_image_serializes_correctly() {
let block = AnthropicContentBlock::Image {
source: ImageSource {
source_type: "base64".to_owned(),
media_type: "image/jpeg".to_owned(),
data: "abc123".to_owned(),
},
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "image");
assert_eq!(json["source"]["type"], "base64");
assert_eq!(json["source"]["media_type"], "image/jpeg");
assert_eq!(json["source"]["data"], "abc123");
}
#[test]
fn split_messages_structured_produces_image_block() {
use base64::{Engine, engine::general_purpose::STANDARD};
let data = vec![0xFFu8, 0xD8, 0xFF];
let msg = Message::from_parts(
Role::User,
vec![
MessagePart::Text {
text: "look at this".into(),
},
MessagePart::Image(Box::new(ImageData {
data: data.clone(),
mime_type: "image/jpeg".into(),
})),
],
);
let (system, chat) = split_messages_structured(&[msg], true);
assert!(system.is_none());
assert_eq!(chat.len(), 1);
assert_eq!(chat[0].role, "user");
match &chat[0].content {
StructuredContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
match &blocks[0] {
AnthropicContentBlock::Text { text, .. } => assert_eq!(text, "look at this"),
_ => panic!("expected Text block first"),
}
match &blocks[1] {
AnthropicContentBlock::Image { source } => {
assert_eq!(source.source_type, "base64");
assert_eq!(source.media_type, "image/jpeg");
assert_eq!(source.data, STANDARD.encode(&data));
}
_ => panic!("expected Image block second"),
}
}
StructuredContent::Text(_) => panic!("expected Blocks content"),
}
}
#[test]
fn tool_cache_returns_same_values_on_second_call() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let tools = vec![ToolDefinition {
name: "bash".into(),
description: "Run shell commands".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
}];
let first = provider.get_or_build_api_tools(&tools);
let second = provider.get_or_build_api_tools(&tools);
assert_eq!(first, second);
assert_eq!(first[0]["name"], "bash");
assert_eq!(first[0]["description"], "Run shell commands");
}
#[test]
fn tool_cache_invalidates_when_tools_change() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let tools_a = vec![ToolDefinition {
name: "bash".into(),
description: "Run shell commands".into(),
parameters: serde_json::json!({}),
}];
let tools_b = vec![ToolDefinition {
name: "read".into(),
description: "Read files".into(),
parameters: serde_json::json!({}),
}];
let first = provider.get_or_build_api_tools(&tools_a);
let second = provider.get_or_build_api_tools(&tools_b);
assert_eq!(first[0]["name"], "bash");
assert_eq!(second[0]["name"], "read");
}
#[test]
fn tool_cache_serialized_shape_snapshot() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let tools = vec![ToolDefinition {
name: "bash".into(),
description: "Run a shell command".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to run"}
},
"required": ["command"]
}),
}];
let cached = provider.get_or_build_api_tools(&tools);
let pretty = serde_json::to_string_pretty(&cached).unwrap();
insta::assert_snapshot!(pretty);
}
async fn spawn_capture_server(
responses: Vec<String>,
) -> (
u16,
tokio::sync::mpsc::Receiver<String>,
tokio::task::JoinHandle<()>,
) {
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = tokio::sync::mpsc::channel(16);
let handle = tokio::spawn(async move {
for resp in responses {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
let tx = tx.clone();
tokio::spawn(async move {
let (reader, mut writer) = stream.split();
let mut buf_reader = BufReader::new(reader);
let mut content_length: usize = 0;
loop {
let mut line = String::new();
buf_reader.read_line(&mut line).await.unwrap_or(0);
if line == "\r\n" || line == "\n" || line.is_empty() {
break;
}
if line.to_lowercase().starts_with("content-length:") {
content_length = line
.split(':')
.nth(1)
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
}
}
let mut body = vec![0u8; content_length];
buf_reader.read_exact(&mut body).await.ok();
let body_str = String::from_utf8_lossy(&body).into_owned();
tx.send(body_str).await.ok();
let resp_bytes = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
resp.len(),
resp
);
writer.write_all(resp_bytes.as_bytes()).await.ok();
});
}
});
(port, rx, handle)
}
fn tool_api_response_json() -> String {
r#"{"content":[{"type":"text","text":"done"}],"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}"#.into()
}
#[tokio::test]
async fn chat_with_tools_sends_correct_tool_fields() {
use crate::provider::ToolDefinition;
let response = tool_api_response_json();
let (port, mut rx, handle) = spawn_capture_server(vec![response]).await;
let client = reqwest::Client::new();
let provider =
ClaudeProvider::new("test-key".into(), "claude-test".into(), 256).with_client(client);
let tools = vec![ToolDefinition {
name: "read_file".into(),
description: "Read a file from disk".into(),
parameters: serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
}];
let messages = vec![Message::from_legacy(Role::User, "read /tmp/f")];
let _ = (port, &mut rx);
let api_tools = provider.get_or_build_api_tools(&tools);
assert_eq!(api_tools.len(), 1);
assert_eq!(api_tools[0]["name"], "read_file");
assert_eq!(api_tools[0]["description"], "Read a file from disk");
assert!(api_tools[0]["input_schema"].is_object());
assert_eq!(api_tools[0]["input_schema"]["type"], "object");
let _ = messages;
handle.abort();
}
#[tokio::test]
async fn chat_with_tools_cache_hit_does_not_re_serialize() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 512);
let tools = vec![
ToolDefinition {
name: "tool_a".into(),
description: "First tool".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
ToolDefinition {
name: "tool_b".into(),
description: "Second tool".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
];
let first = provider.get_or_build_api_tools(&tools);
let second = provider.get_or_build_api_tools(&tools);
let third = provider.get_or_build_api_tools(&tools);
assert_eq!(first, second);
assert_eq!(second, third);
assert_eq!(first.len(), 2);
assert_eq!(first[0]["name"], "tool_a");
assert_eq!(first[1]["name"], "tool_b");
let guard = provider.tool_cache.lock();
let (hash, values) = guard.as_ref().unwrap();
assert_ne!(*hash, 0);
assert_eq!(values.len(), 2);
}
#[tokio::test]
async fn chat_with_tools_cache_partial_tool_set_change_invalidates() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 512);
let tools_v1 = vec![ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
}];
let tools_v2 = vec![
ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
ToolDefinition {
name: "browse".into(),
description: "Browse a URL".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
];
let v1 = provider.get_or_build_api_tools(&tools_v1);
assert_eq!(v1.len(), 1);
let v2 = provider.get_or_build_api_tools(&tools_v2);
assert_eq!(v2.len(), 2);
assert_eq!(v2[1]["name"], "browse");
let guard = provider.tool_cache.lock();
let (hash, values) = guard.as_ref().unwrap();
assert_ne!(*hash, 0);
assert_eq!(values.len(), 2);
}
#[test]
fn has_image_parts_detects_image_in_messages() {
let with_image = Message::from_parts(
Role::User,
vec![MessagePart::Image(Box::new(ImageData {
data: vec![1],
mime_type: "image/png".into(),
}))],
);
let without_image = Message::from_legacy(Role::User, "plain text");
assert!(ClaudeProvider::has_image_parts(&[with_image]));
assert!(!ClaudeProvider::has_image_parts(&[without_image]));
}
#[test]
fn pagination_response_has_more_true_extracts_last_id() {
let page = serde_json::json!({
"data": [{"id": "model-a", "type": "model", "display_name": "Model A"}],
"has_more": true,
"last_id": "model-a"
});
let has_more = page
.get("has_more")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let last_id = page
.get("last_id")
.and_then(|v| v.as_str())
.map(str::to_owned);
assert!(has_more);
assert_eq!(last_id, Some("model-a".to_string()));
}
#[test]
fn pagination_response_has_more_false_stops_loop() {
let page = serde_json::json!({
"data": [{"id": "model-b", "type": "model", "display_name": "Model B"}],
"has_more": false
});
let has_more = page
.get("has_more")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
assert!(!has_more);
}
#[test]
fn model_item_filters_non_model_type() {
let page = serde_json::json!({
"data": [
{"id": "model-ok", "type": "model", "display_name": "OK"},
{"id": "skip-me", "type": "other", "display_name": "Skip"}
],
"has_more": false
});
let models: Vec<crate::model_cache::RemoteModelInfo> = page
.get("data")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let type_field = item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default();
if type_field != "model" {
return None;
}
let id = item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let display_name = item
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
Some(crate::model_cache::RemoteModelInfo {
id,
display_name,
context_window: None,
created_at: None,
})
})
.collect()
})
.unwrap_or_default();
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "model-ok");
}
#[test]
fn model_item_uses_id_as_display_name_when_missing() {
let item = serde_json::json!({"id": "claude-x", "type": "model"});
let id = item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let display_name = item
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
assert_eq!(display_name, "claude-x");
}
#[test]
fn messages_response_deserialization() {
let raw = serde_json::json!({
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "hello claude"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 10,
"output_tokens": 5,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
});
let resp: ApiResponse = serde_json::from_value(raw).unwrap();
let text: String = resp.content.iter().map(|b| b.text.as_str()).collect();
assert_eq!(text, "hello claude");
}
#[tokio::test]
async fn messages_429_overload_propagates() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
use crate::testing::claude_overload_response;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(claude_overload_response(429))
.mount(&server)
.await;
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", server.uri()))
.json(&serde_json::json!({}))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 429);
}
#[tokio::test]
async fn messages_529_overload_propagates() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
use crate::testing::claude_overload_response;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(claude_overload_response(529))
.mount(&server)
.await;
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", server.uri()))
.json(&serde_json::json!({}))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 529);
}
#[tokio::test]
async fn claude_sse_fixture_contains_expected_events() {
use crate::testing::claude_sse_stream_response;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/stream"))
.respond_with(claude_sse_stream_response(&["Hello", " world"]))
.mount(&server)
.await;
let raw = reqwest::Client::new()
.post(format!("{}/stream", server.uri()))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(raw.contains("Hello"));
assert!(raw.contains(" world"));
assert!(raw.contains("message_stop"));
assert!(raw.contains("content_block_delta"));
}
#[test]
fn thinking_config_extended_serializes() {
let cfg = ThinkingConfig::Extended {
budget_tokens: 10_000,
};
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["mode"], "extended");
assert_eq!(json["budget_tokens"], 10_000);
}
#[test]
fn thinking_config_adaptive_serializes_without_effort() {
let cfg = ThinkingConfig::Adaptive { effort: None };
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["mode"], "adaptive");
assert!(json.get("effort").is_none());
}
#[test]
fn thinking_config_adaptive_serializes_with_effort() {
let cfg = ThinkingConfig::Adaptive {
effort: Some(ThinkingEffort::High),
};
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["mode"], "adaptive");
assert_eq!(json["effort"], "high");
}
#[test]
fn thinking_config_extended_deserializes() {
let json = r#"{"mode":"extended","budget_tokens":8000}"#;
let cfg: ThinkingConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg,
ThinkingConfig::Extended {
budget_tokens: 8000
}
);
}
#[test]
fn thinking_config_adaptive_deserializes() {
let json = r#"{"mode":"adaptive","effort":"low"}"#;
let cfg: ThinkingConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg,
ThinkingConfig::Adaptive {
effort: Some(ThinkingEffort::Low)
}
);
}
#[test]
fn thinking_capability_sonnet_4_6_needs_interleaved_beta() {
let cap = thinking_capability("claude-sonnet-4-6-20250514");
assert!(cap.needs_interleaved_beta);
}
#[test]
fn thinking_capability_opus_4_6_no_interleaved_beta() {
let cap = thinking_capability("claude-opus-4-6");
assert!(!cap.needs_interleaved_beta);
}
#[test]
fn thinking_capability_unknown_model_no_beta() {
let cap = thinking_capability("gpt-4o");
assert!(!cap.needs_interleaved_beta);
}
#[test]
fn thinking_capability_opus_4_6_prefers_effort() {
let cap = thinking_capability("claude-opus-4-6");
assert!(cap.prefers_effort);
}
#[test]
fn thinking_capability_sonnet_4_6_no_prefers_effort() {
let cap = thinking_capability("claude-sonnet-4-6-20250514");
assert!(!cap.prefers_effort);
}
#[test]
fn budget_to_effort_boundaries() {
assert_eq!(budget_to_effort(4_999), ThinkingEffort::Low);
assert_eq!(budget_to_effort(5_000), ThinkingEffort::Medium);
assert_eq!(budget_to_effort(14_999), ThinkingEffort::Medium);
assert_eq!(budget_to_effort(15_000), ThinkingEffort::High);
assert_eq!(budget_to_effort(1_024), ThinkingEffort::Low);
assert_eq!(budget_to_effort(20_000), ThinkingEffort::High);
}
#[test]
fn build_thinking_param_opus_extended_converts_to_adaptive() {
let p = ClaudeProvider::new("k".into(), "claude-opus-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5_000,
})
.unwrap();
let (param, _temp, effort) = p.build_thinking_param();
let param = param.unwrap();
assert_eq!(param.thinking_type, "adaptive");
assert!(param.budget_tokens.is_none());
assert_eq!(effort, Some(ThinkingEffort::Medium));
}
#[test]
fn build_thinking_param_opus_adaptive_unchanged() {
let p = ClaudeProvider::new("k".into(), "claude-opus-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Adaptive {
effort: Some(ThinkingEffort::High),
})
.unwrap();
let (param, _temp, effort) = p.build_thinking_param();
let param = param.unwrap();
assert_eq!(param.thinking_type, "adaptive");
assert!(param.budget_tokens.is_none());
assert_eq!(effort, Some(ThinkingEffort::High));
}
#[test]
fn build_thinking_param_sonnet_extended_unchanged() {
let p = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5_000,
})
.unwrap();
let (param, _temp, effort) = p.build_thinking_param();
let param = param.unwrap();
assert_eq!(param.thinking_type, "enabled");
assert_eq!(param.budget_tokens, Some(5_000));
assert!(effort.is_none());
}
#[test]
fn with_thinking_rejects_budget_below_minimum() {
let err = ClaudeProvider::new("k".into(), "m".into(), 32_000)
.with_thinking(ThinkingConfig::Extended { budget_tokens: 0 })
.unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
let err = ClaudeProvider::new("k".into(), "m".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 1023,
})
.unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test]
fn with_thinking_accepts_minimum_budget() {
ClaudeProvider::new("k".into(), "m".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 1024,
})
.unwrap();
}
#[test]
fn with_thinking_accepts_maximum_budget() {
ClaudeProvider::new("k".into(), "m".into(), 256_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 128_000,
})
.unwrap();
}
#[test]
fn with_thinking_rejects_budget_above_maximum() {
let err = ClaudeProvider::new("k".into(), "m".into(), 256_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 128_001,
})
.unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test]
fn with_thinking_rejects_budget_not_less_than_max_tokens() {
let err = ClaudeProvider::new("k".into(), "m".into(), 1024)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 16_000,
})
.unwrap_err();
assert!(err.to_string().contains("less than max_tokens"), "{err}");
}
#[test]
fn with_thinking_bumps_max_tokens_when_too_low() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 1024)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 8000,
})
.unwrap();
assert!(provider.max_tokens >= MIN_MAX_TOKENS_WITH_THINKING);
}
#[test]
fn with_thinking_keeps_max_tokens_when_already_high() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 8000,
})
.unwrap();
assert_eq!(provider.max_tokens, 32_000);
}
#[test]
fn build_thinking_param_extended_returns_enabled_with_budget() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 16_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5000,
})
.unwrap();
let (param, temp, effort) = provider.build_thinking_param();
let param = param.unwrap();
assert_eq!(param.thinking_type, "enabled");
assert_eq!(param.budget_tokens, Some(5000));
assert!(temp.is_none());
assert!(effort.is_none());
}
#[test]
fn build_thinking_param_adaptive_returns_adaptive_type() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 16_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let (param, temp, effort) = provider.build_thinking_param();
let param = param.unwrap();
assert_eq!(param.thinking_type, "adaptive");
assert!(param.budget_tokens.is_none());
assert!(temp.is_none());
assert!(effort.is_none());
}
#[test]
fn build_thinking_param_adaptive_with_effort_returns_effort() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 16_000)
.with_thinking(ThinkingConfig::Adaptive {
effort: Some(ThinkingEffort::High),
})
.unwrap();
let (param, temp, effort) = provider.build_thinking_param();
let param = param.unwrap();
assert_eq!(param.thinking_type, "adaptive");
assert!(param.budget_tokens.is_none());
assert!(temp.is_none());
assert_eq!(effort, Some(ThinkingEffort::High));
}
#[test]
fn build_thinking_param_adaptive_serializes_correctly() {
let param = ThinkingParam {
thinking_type: "adaptive",
budget_tokens: None,
};
let json = serde_json::to_value(¶m).unwrap();
assert_eq!(json, serde_json::json!({"type": "adaptive"}));
assert!(json.get("budget_tokens").is_none());
}
#[test]
fn build_thinking_param_no_thinking_returns_none() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 1024);
let (param, temp, effort) = provider.build_thinking_param();
assert!(param.is_none());
assert!(temp.is_none());
assert!(effort.is_none());
}
#[test]
fn beta_header_without_thinking_returns_none() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 1024);
let beta = provider.beta_header(true);
assert!(beta.is_none());
}
#[test]
fn beta_header_sonnet_4_6_extended_with_tools_includes_interleaved() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 16_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5000,
})
.unwrap();
let beta = provider.beta_header(true);
assert!(
beta.as_deref()
.is_some_and(|b| b.contains(ANTHROPIC_BETA_INTERLEAVED_THINKING))
);
}
#[test]
fn beta_header_sonnet_4_6_extended_no_tools_excludes_interleaved() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 16_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5000,
})
.unwrap();
let beta = provider.beta_header(false);
assert!(beta.is_none());
}
#[test]
fn beta_header_adaptive_mode_excludes_interleaved() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 16_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let beta = provider.beta_header(true);
assert!(beta.is_none());
}
#[test]
fn extended_context_disabled_no_beta_header() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 1024);
let beta = provider.beta_header(true);
assert!(beta.is_none());
}
#[test]
fn extended_context_enabled_includes_beta_header() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 1024)
.with_extended_context(true);
let beta = provider.beta_header(true);
assert!(
beta.as_deref()
.is_some_and(|b| b.contains(ANTHROPIC_BETA_EXTENDED_CONTEXT))
);
}
#[test]
fn extended_context_with_interleaved_thinking_combines_headers() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 16_000)
.with_extended_context(true)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5000,
})
.unwrap();
let beta = provider.beta_header(true);
let beta_str = beta.expect("beta header should be present");
assert!(beta_str.contains(ANTHROPIC_BETA_EXTENDED_CONTEXT));
assert!(beta_str.contains(ANTHROPIC_BETA_INTERLEAVED_THINKING));
assert!(beta_str.contains(','));
}
#[test]
fn extended_context_enabled_returns_1m_context_window() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 1024)
.with_extended_context(true);
assert_eq!(provider.context_window(), Some(1_000_000));
}
#[test]
fn extended_context_disabled_returns_200k_context_window() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-6".into(), 1024);
assert_eq!(provider.context_window(), Some(200_000));
}
#[test]
fn extended_context_enabled_haiku_returns_200k_context_window() {
let provider = ClaudeProvider::new("k".into(), "claude-haiku-4-5-20251001".into(), 1024)
.with_extended_context(true);
assert_eq!(provider.context_window(), Some(200_000));
}
#[test]
fn parse_tool_response_with_thinking_blocks() {
let resp = ToolApiResponse {
content: vec![
AnthropicContentBlock::Thinking {
thinking: "let me think".into(),
signature: "sig123".into(),
},
AnthropicContentBlock::ToolUse {
id: "toolu_1".into(),
name: "bash".into(),
input: serde_json::json!({"command": "ls"}),
},
],
stop_reason: None,
usage: None,
};
let (result, _) = parse_tool_response(resp);
if let ChatResponse::ToolUse {
thinking_blocks,
tool_calls,
..
} = result
{
assert_eq!(tool_calls.len(), 1);
assert_eq!(thinking_blocks.len(), 1);
if let ThinkingBlock::Thinking {
thinking,
signature,
} = &thinking_blocks[0]
{
assert_eq!(thinking, "let me think");
assert_eq!(signature, "sig123");
} else {
panic!("expected Thinking variant");
}
} else {
panic!("expected ToolUse");
}
}
#[test]
fn parse_tool_response_with_redacted_thinking() {
let resp = ToolApiResponse {
content: vec![
AnthropicContentBlock::RedactedThinking {
data: "redacted".into(),
},
AnthropicContentBlock::Text {
text: "result".into(),
cache_control: None,
},
],
stop_reason: None,
usage: None,
};
let (result, _) = parse_tool_response(resp);
assert!(matches!(result, ChatResponse::Text(_)));
}
#[test]
fn thinking_block_serializes_in_structured_message() {
let msg = Message::from_parts(
Role::Assistant,
vec![
MessagePart::ThinkingBlock {
thinking: "my reasoning".into(),
signature: "abc".into(),
},
MessagePart::Text {
text: "answer".into(),
},
],
);
let (_, chat) = split_messages_structured(&[msg], true);
assert_eq!(chat.len(), 1);
let json = serde_json::to_value(&chat[0]).unwrap();
let blocks = json["content"].as_array().unwrap();
assert_eq!(blocks[0]["type"], "thinking");
assert_eq!(blocks[0]["thinking"], "my reasoning");
assert_eq!(blocks[0]["signature"], "abc");
assert_eq!(blocks[1]["type"], "text");
}
#[test]
fn redacted_thinking_block_serializes_in_structured_message() {
let msg = Message::from_parts(
Role::Assistant,
vec![MessagePart::RedactedThinkingBlock {
data: "secret".into(),
}],
);
let (_, chat) = split_messages_structured(&[msg], true);
let json = serde_json::to_value(&chat[0]).unwrap();
let blocks = json["content"].as_array().unwrap();
assert_eq!(blocks[0]["type"], "redacted_thinking");
assert_eq!(blocks[0]["data"], "secret");
}
#[test]
fn thinking_content_block_roundtrip() {
let block = AnthropicContentBlock::Thinking {
thinking: "internal reasoning".into(),
signature: "signature-data".into(),
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "thinking");
let restored: AnthropicContentBlock = serde_json::from_value(json).unwrap();
if let AnthropicContentBlock::Thinking {
thinking,
signature,
} = restored
{
assert_eq!(thinking, "internal reasoning");
assert_eq!(signature, "signature-data");
} else {
panic!("expected Thinking");
}
}
#[test]
fn redacted_thinking_content_block_roundtrip() {
let block = AnthropicContentBlock::RedactedThinking {
data: "opaque-data".into(),
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "redacted_thinking");
let restored: AnthropicContentBlock = serde_json::from_value(json).unwrap();
if let AnthropicContentBlock::RedactedThinking { data } = restored {
assert_eq!(data, "opaque-data");
} else {
panic!("expected RedactedThinking");
}
}
#[test]
fn build_request_does_not_include_anthropic_beta_header() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 256);
let messages = vec![Message {
role: Role::User,
content: "hi".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let req = provider.build_request(&messages, false).build().unwrap();
assert!(
req.headers().get("anthropic-beta").is_none(),
"anthropic-beta header must not be present"
);
assert!(req.headers().get("anthropic-version").is_some());
assert!(req.headers().get("x-api-key").is_some());
}
#[test]
fn build_request_with_extended_context_includes_beta_header() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 256)
.with_extended_context(true);
let messages = vec![Message {
role: Role::User,
content: "hi".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let req = provider.build_request(&messages, false).build().unwrap();
let header_value = req
.headers()
.get("anthropic-beta")
.expect("anthropic-beta header must be present when extended context is enabled")
.to_str()
.expect("header must be valid UTF-8");
assert!(
header_value.contains(ANTHROPIC_BETA_EXTENDED_CONTEXT),
"anthropic-beta header must contain '{ANTHROPIC_BETA_EXTENDED_CONTEXT}', got '{header_value}'"
);
}
#[test]
fn get_or_build_api_tools_only_last_tool_has_cache_control() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 512);
let tools = vec![
ToolDefinition {
name: "alpha".into(),
description: "First".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
ToolDefinition {
name: "beta".into(),
description: "Second".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
ToolDefinition {
name: "gamma".into(),
description: "Third".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
},
];
let result = provider.get_or_build_api_tools(&tools);
assert_eq!(result.len(), 3);
assert!(
result[0].get("cache_control").is_none(),
"first tool must not have cache_control"
);
assert!(
result[1].get("cache_control").is_none(),
"middle tool must not have cache_control"
);
assert!(
result[2].get("cache_control").is_some(),
"last tool must have cache_control"
);
assert_eq!(result[2]["cache_control"]["type"], "ephemeral");
}
#[test]
fn get_or_build_api_tools_single_tool_has_cache_control() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 512);
let tools = vec![ToolDefinition {
name: "only".into(),
description: "Only tool".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
}];
let result = provider.get_or_build_api_tools(&tools);
assert_eq!(result.len(), 1);
assert!(result[0].get("cache_control").is_some());
assert_eq!(result[0]["cache_control"]["type"], "ephemeral");
}
#[test]
fn cache_min_tokens_sonnet_returns_2048() {
assert_eq!(cache_min_tokens("claude-sonnet-4-6"), 2048);
assert_eq!(cache_min_tokens("claude-sonnet-4-5-20250929"), 2048);
}
#[test]
fn cache_min_tokens_non_sonnet_returns_4096() {
assert_eq!(cache_min_tokens("claude-opus-4-6"), 4096);
assert_eq!(cache_min_tokens("claude-haiku-4-5"), 4096);
assert_eq!(cache_min_tokens("unknown-model"), 4096);
}
#[test]
fn split_system_opus_block_above_threshold_gets_cache_control() {
let padding = "x".repeat(16400);
let system = format!("{padding}\n{CACHE_MARKER_STABLE}\nmore");
let blocks = split_system_into_blocks(&system, "claude-opus-4-6");
assert!(
blocks[0].cache_control.is_some(),
"block above opus threshold must be cached"
);
}
#[test]
fn split_system_opus_block_below_threshold_skips_cache_control() {
let system = format!("short\n{CACHE_MARKER_STABLE}\nmore content");
let blocks = split_system_into_blocks(&system, "claude-opus-4-6");
assert!(
blocks[0].cache_control.is_none(),
"block below opus threshold must not be cached"
);
}
#[test]
fn build_request_single_message_no_top_level_cache_control() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 256);
let messages = vec![Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let req = provider.build_request(&messages, false).build().unwrap();
let body: serde_json::Value =
serde_json::from_slice(req.body().and_then(|b| b.as_bytes()).unwrap()).unwrap();
assert!(
body.get("cache_control").is_none(),
"single-turn request must not have top-level cache_control"
);
}
#[test]
fn build_request_multi_turn_no_top_level_cache_control() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 256);
let messages = vec![
Message {
role: Role::User,
content: "first".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "reply".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::User,
content: "second".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let req = provider.build_request(&messages, false).build().unwrap();
let body: serde_json::Value =
serde_json::from_slice(req.body().and_then(|b| b.as_bytes()).unwrap()).unwrap();
assert!(
body.get("cache_control").is_none(),
"multi-turn request must not have top-level cache_control"
);
}
#[test]
fn split_messages_structured_single_message_no_cache_breakpoint() {
let messages = vec![Message {
role: Role::User,
content: "only message".into(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let (_, chat) = split_messages_structured(&messages, true);
assert_eq!(chat.len(), 1);
let json = serde_json::to_value(&chat[0]).unwrap();
let has_cache = json.to_string().contains("cache_control");
assert!(
!has_cache,
"single message must not have cache_control breakpoint"
);
}
#[test]
fn split_messages_structured_two_messages_places_breakpoint_on_user() {
let messages = vec![
Message {
role: Role::User,
content: "first user".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "assistant reply".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let (_, chat) = split_messages_structured(&messages, true);
assert_eq!(chat.len(), 2);
let user_json = serde_json::to_value(&chat[0]).unwrap();
assert!(
user_json.to_string().contains("cache_control"),
"user message must carry cache_control breakpoint"
);
let assistant_json = serde_json::to_value(&chat[1]).unwrap();
assert!(
!assistant_json.to_string().contains("cache_control"),
"assistant message must not have cache_control"
);
}
#[test]
fn split_messages_structured_breakpoint_targets_last_minus_20_position() {
let mut messages = Vec::new();
for i in 0..25u32 {
let role = if i % 2 == 0 {
Role::User
} else {
Role::Assistant
};
let content = format!("message {i}");
messages.push(Message {
role,
content,
parts: vec![],
metadata: MessageMetadata::default(),
});
}
let (_, chat) = split_messages_structured(&messages, true);
assert_eq!(chat.len(), 25);
let mut breakpoint_idx = None;
for (i, msg) in chat.iter().enumerate() {
let json = serde_json::to_value(msg).unwrap();
if json.to_string().contains("cache_control") {
breakpoint_idx = Some(i);
break;
}
}
let idx = breakpoint_idx.expect("must have a breakpoint somewhere");
assert_eq!(
chat[idx].role, "user",
"breakpoint must be on a user message"
);
assert!(idx >= 5, "breakpoint must be at or after position total-20");
}
fn count_cache_control_occurrences(value: &serde_json::Value) -> usize {
match value {
serde_json::Value::Object(map) => {
usize::from(map.contains_key("cache_control"))
+ map
.values()
.map(count_cache_control_occurrences)
.sum::<usize>()
}
serde_json::Value::Array(items) => items.iter().map(count_cache_control_occurrences).sum(),
_ => 0,
}
}
#[test]
fn debug_tool_request_caps_block_cache_controls_at_four() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 256);
let padding = "x".repeat(8200);
let system = format!(
"base prompt {padding}\n{CACHE_MARKER_STABLE}\nskills here {padding}\n\
{CACHE_MARKER_TOOLS}\ntool catalog {padding}\n\
{CACHE_MARKER_VOLATILE}\nvolatile stuff"
);
let messages = vec![
Message::from_legacy(Role::System, system),
Message::from_legacy(Role::User, "diagnose ACP startup"),
Message::from_parts(
Role::Assistant,
vec![MessagePart::ToolUse {
id: "toolu_1".into(),
name: "bash".into(),
input: serde_json::json!({"command": "false"}),
}],
),
Message::from_parts(
Role::User,
vec![MessagePart::ToolResult {
tool_use_id: "toolu_1".into(),
content: "command failed".into(),
is_error: true,
}],
),
];
let tools = vec![ToolDefinition {
name: "bash".into(),
description: "Run shell commands".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"]
}),
}];
let body = provider.debug_request_json(&messages, &tools, false);
assert_eq!(
count_cache_control_occurrences(&body["tools"]),
1,
"tool definitions should keep their cache breakpoint"
);
assert_eq!(
count_cache_control_occurrences(&body["system"]),
3,
"system markers should keep all three cacheable blocks"
);
assert_eq!(
count_cache_control_occurrences(&body["messages"]),
0,
"message-level cache breakpoint must be dropped when tools+system already consume the Anthropic budget"
);
assert_eq!(
count_cache_control_occurrences(&body["tools"])
+ count_cache_control_occurrences(&body["system"])
+ count_cache_control_occurrences(&body["messages"]),
4,
"tool requests must never serialize more than four nested cache_control entries"
);
assert_eq!(
count_cache_control_occurrences(&body),
4,
"tool requests must stay within Anthropic's total cache_control budget"
);
assert!(
body.get("cache_control").is_none(),
"top-level cache_control must be dropped when tools and system blocks already consume the budget"
);
}
#[test]
fn debug_vision_request_caps_total_cache_controls_at_four() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 256);
let padding = "x".repeat(8200);
let system = format!(
"base prompt {padding}\n{CACHE_MARKER_STABLE}\nskills here {padding}\n\
{CACHE_MARKER_TOOLS}\ntool catalog {padding}\n\
{CACHE_MARKER_VOLATILE}\nvolatile stuff"
);
let messages = vec![
Message::from_legacy(Role::System, system),
Message::from_legacy(Role::User, "describe this screenshot"),
Message::from_parts(
Role::User,
vec![MessagePart::Image(Box::new(ImageData {
data: vec![1, 2, 3, 4],
mime_type: "image/png".into(),
}))],
),
];
let body = provider.debug_request_json(&messages, &[], false);
assert_eq!(
count_cache_control_occurrences(&body["system"]),
3,
"system markers should keep all three cacheable blocks"
);
assert_eq!(
count_cache_control_occurrences(&body["messages"]),
1,
"vision requests may keep one message breakpoint when system blocks consume only three slots"
);
assert_eq!(
count_cache_control_occurrences(&body),
4,
"vision requests must stay within Anthropic's total cache_control budget"
);
assert!(
body.get("cache_control").is_none(),
"vision requests must not serialize top-level cache_control"
);
}
#[test]
fn tool_cache_invalidates_on_schema_change() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let tools_v1 = vec![ToolDefinition {
name: "tool".into(),
description: "desc".into(),
parameters: serde_json::json!({"type": "object", "properties": {"a": {"type": "string"}}}),
}];
let tools_v2 = vec![ToolDefinition {
name: "tool".into(),
description: "desc".into(),
parameters: serde_json::json!({"type": "object", "properties": {"b": {"type": "number"}}}),
}];
let first = provider.get_or_build_api_tools(&tools_v1);
let second = provider.get_or_build_api_tools(&tools_v2);
assert_eq!(
first[0]["input_schema"]["properties"]["a"]["type"],
"string"
);
assert_eq!(
second[0]["input_schema"]["properties"]["b"]["type"],
"number"
);
assert_ne!(tool_cache_key(&tools_v1), tool_cache_key(&tools_v2));
}
#[test]
fn tool_cache_hits_on_same_tools() {
use crate::provider::ToolDefinition;
let provider = ClaudeProvider::new("key".into(), "model".into(), 1024);
let tools = vec![ToolDefinition {
name: "bash".into(),
description: "Run".into(),
parameters: serde_json::json!({"type": "object"}),
}];
let first = provider.get_or_build_api_tools(&tools);
let second = provider.get_or_build_api_tools(&tools);
assert_eq!(first, second);
let expected = tool_cache_key(&tools);
let cached_hash = provider.tool_cache.lock().as_ref().map(|(h, _)| *h);
assert_eq!(cached_hash, Some(expected));
}
#[test]
fn split_messages_structured_cache_enabled_adds_cache_control() {
let messages = vec![
Message::from_legacy(Role::User, "first"),
Message::from_legacy(Role::Assistant, "answer"),
Message::from_legacy(Role::User, "second"),
];
let (_, chat) = split_messages_structured(&messages, true);
assert_eq!(chat.len(), 3);
let has_cache = chat.iter().any(|m| {
m.role == "user"
&& match &m.content {
StructuredContent::Blocks(blocks) => blocks.iter().any(|b| {
matches!(
b,
AnthropicContentBlock::Text {
cache_control: Some(_),
..
}
)
}),
StructuredContent::Text(_) => false,
}
});
assert!(
has_cache,
"at least one user message must have cache_control when enabled"
);
}
#[test]
fn split_messages_structured_cache_disabled_no_cache_control() {
let messages = vec![
Message::from_legacy(Role::User, "first"),
Message::from_legacy(Role::Assistant, "answer"),
Message::from_legacy(Role::User, "second"),
];
let (_, chat) = split_messages_structured(&messages, false);
assert_eq!(chat.len(), 3);
assert!(
matches!(&chat[2].content, StructuredContent::Text(_)),
"last user message must remain Text when cache disabled"
);
}
#[test]
fn with_cache_user_messages_builder() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 256).with_cache_user_messages(false);
assert!(!provider.cache_user_messages);
let provider2 = ClaudeProvider::new("k".into(), "m".into(), 256);
assert!(provider2.cache_user_messages);
}
#[test]
fn clone_preserves_cache_user_messages() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 256).with_cache_user_messages(false);
let cloned = provider.clone();
assert!(!cloned.cache_user_messages);
}
#[test]
fn store_cache_usage_updates_last_usage() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 256);
assert!(provider.last_usage().is_none());
let usage = ApiUsage {
input_tokens: 42,
output_tokens: 17,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
};
provider.store_cache_usage(&usage);
assert_eq!(provider.last_usage(), Some((42, 17)));
}
#[test]
fn build_request_opus_thinking_strips_trailing_assistant() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let messages = vec![
Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "world".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let req = provider.build_request(&messages, false).build().unwrap();
let body: serde_json::Value =
serde_json::from_slice(req.body().and_then(|b| b.as_bytes()).unwrap()).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert!(
msgs.last()
.and_then(|m| m["role"].as_str())
.is_none_or(|r| r != "assistant"),
"trailing assistant message must be stripped for Opus 4.6 with thinking"
);
}
#[test]
fn build_request_opus_no_thinking_keeps_trailing_assistant() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-6".into(), 32_000);
let messages = vec![
Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "world".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let req = provider.build_request(&messages, false).build().unwrap();
let body: serde_json::Value =
serde_json::from_slice(req.body().and_then(|b| b.as_bytes()).unwrap()).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.last().and_then(|m| m["role"].as_str()),
Some("assistant"),
"trailing assistant message must be preserved when thinking is disabled"
);
}
#[test]
fn build_request_sonnet_thinking_keeps_trailing_assistant() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5_000,
})
.unwrap();
let messages = vec![
Message {
role: Role::User,
content: "hello".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
Message {
role: Role::Assistant,
content: "world".into(),
parts: vec![],
metadata: MessageMetadata::default(),
},
];
let req = provider.build_request(&messages, false).build().unwrap();
let body: serde_json::Value =
serde_json::from_slice(req.body().and_then(|b| b.as_bytes()).unwrap()).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.last().and_then(|m| m["role"].as_str()),
Some("assistant"),
"Sonnet 4.6 must not strip trailing assistant messages"
);
}
#[test]
fn server_compaction_disabled_by_default() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024);
assert!(!provider.server_compaction_enabled());
}
#[test]
fn with_server_compaction_enables_flag() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
assert!(provider.server_compaction_enabled());
}
#[test]
fn with_server_compaction_haiku_stays_disabled() {
let provider = ClaudeProvider::new("key".into(), "claude-haiku-4-5-20251001".into(), 1024)
.with_server_compaction(true);
assert!(!provider.server_compaction_enabled());
}
#[test]
fn take_compaction_summary_empty_when_none() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024);
assert!(provider.take_compaction_summary().is_none());
}
#[test]
fn take_compaction_summary_returns_and_clears() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024);
*provider.last_compaction.lock() = Some("Summary text".to_owned());
let result = provider.take_compaction_summary();
assert_eq!(result.as_deref(), Some("Summary text"));
assert!(provider.take_compaction_summary().is_none());
}
#[test]
fn context_management_absent_when_disabled() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024);
assert!(provider.context_management().is_none());
}
#[test]
fn context_management_present_when_enabled() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
let cm = provider.context_management().unwrap();
assert_eq!(cm.trigger.value, 160_000);
}
#[test]
fn context_management_serializes_correctly() {
let cm = ContextManagement {
trigger: ContextManagementTrigger {
kind: "input_tokens",
value: 160_000,
},
pause_after_compaction: false,
};
let json = serde_json::to_value(&cm).unwrap();
assert!(
json.get("type").is_none(),
"context_management must not have a top-level 'type' field"
);
assert_eq!(json["trigger"]["type"], "input_tokens");
assert_eq!(json["trigger"]["value"], 160_000);
assert_eq!(json["pause_after_compaction"], false);
}
#[test]
fn beta_header_includes_compact_when_server_compaction_enabled() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
let header = provider.beta_header(false).unwrap_or_default();
assert!(
header.contains("compact-2026-01-12"),
"beta header must include compact beta when server_compaction is on"
);
}
#[test]
fn beta_header_excludes_compact_when_disabled() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024);
let header = provider.beta_header(false).unwrap_or_default();
assert!(
!header.contains("compact-2026-01-12"),
"beta header must not include compact beta when server_compaction is off"
);
}
#[test]
fn compaction_content_block_deserialized() {
let json = r#"{"type":"compaction","summary":"Context summary here"}"#;
let block: AnthropicContentBlock = serde_json::from_str(json).unwrap();
assert!(
matches!(block, AnthropicContentBlock::Compaction { summary } if summary == "Context summary here")
);
}
#[test]
fn server_compaction_not_rejected_by_default() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
assert!(!provider.is_server_compaction_rejected());
}
#[test]
fn beta_header_excluded_after_rejection() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
provider
.server_compaction_rejected
.store(true, std::sync::atomic::Ordering::Relaxed);
let header = provider.beta_header(false).unwrap_or_default();
assert!(
!header.contains("compact-2026-01-12"),
"compact beta must be excluded once rejected"
);
}
#[test]
fn context_management_absent_after_rejection() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
provider
.server_compaction_rejected
.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
provider.context_management().is_none(),
"context_management must be None after beta header rejection"
);
}
#[test]
fn is_compact_beta_rejection_detects_unknown_beta() {
assert!(ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"type":"error","error":{"type":"invalid_request_error","message":"unknown beta: compact-2026-01-12"}}"#
));
}
#[test]
fn is_compact_beta_rejection_detects_invalid_beta_keyword() {
assert!(ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"error":{"message":"invalid beta header supplied"}}"#
));
}
#[test]
fn is_compact_beta_rejection_ignores_non_400() {
assert!(!ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::UNAUTHORIZED,
"unknown beta: compact-2026-01-12"
));
}
#[test]
fn is_compact_beta_rejection_ignores_unrelated_400() {
assert!(!ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"error":{"message":"invalid parameter: model"}}"#
));
}
#[test]
fn is_compact_beta_rejection_detects_context_management_extra_inputs() {
assert!(ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"type":"error","error":{"type":"invalid_request_error","message":"context_management.type: Extra inputs are not permitted"}}"#
));
}
#[test]
fn is_compact_beta_rejection_detects_context_management_generic() {
assert!(ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"type":"error","error":{"type":"invalid_request_error","message":"context_management: field not allowed"}}"#
));
}
#[test]
fn is_compact_beta_rejection_ignores_unrelated_no_context_management() {
assert!(!ClaudeProvider::is_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: field required"}}"#
));
}
#[test]
fn clone_shares_rejection_flag() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 1024)
.with_server_compaction(true);
let clone = provider.clone();
provider
.server_compaction_rejected
.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
clone.is_server_compaction_rejected(),
"clone must share the rejection Arc"
);
}
#[test]
fn split_messages_structured_compaction_round_trip() {
let messages = vec![
Message::from_parts(
Role::Assistant,
vec![
MessagePart::Text {
text: "Before compaction.".into(),
},
MessagePart::Compaction {
summary: "History was compacted here.".into(),
},
],
),
Message::from_parts(
Role::User,
vec![
MessagePart::Text {
text: "Continue.".into(),
},
MessagePart::Compaction {
summary: "should be dropped".into(),
},
],
),
];
let (system, chat) = split_messages_structured(&messages, false);
assert!(system.is_none());
assert_eq!(chat.len(), 2);
if let StructuredContent::Blocks(blocks) = &chat[0].content {
let has_compaction = blocks.iter().any(|b| {
matches!(b, AnthropicContentBlock::Compaction { summary }
if summary == "History was compacted here.")
});
assert!(
has_compaction,
"assistant Compaction block must be preserved"
);
} else {
panic!("expected Blocks for assistant message");
}
let user_json = serde_json::to_string(&chat[1]).unwrap();
assert!(
!user_json.contains("compaction"),
"Compaction in user message must be dropped"
);
}