use super::cache::cache_min_tokens;
use std::assert_matches;
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::CacheTtl;
use crate::provider::{ImageData, MessageMetadata, Role, ThinkingBlock};
use crate::testing::TEST_CLAUDE_MODEL;
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-8".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 stale_model_suggestion_old_gen_below_floor_returns_some() {
assert_eq!(
stale_model_suggestion("claude-sonnet-4-6"),
Some("claude-sonnet-5")
);
assert_eq!(
stale_model_suggestion("claude-opus-4-6"),
Some("claude-opus-4-8")
);
assert_eq!(
stale_model_suggestion("claude-opus-4-7"),
Some("claude-opus-4-8")
);
}
#[test]
fn stale_model_suggestion_current_gen_returns_none() {
assert_eq!(stale_model_suggestion("claude-sonnet-5"), None);
assert_eq!(stale_model_suggestion("claude-opus-4-8"), None);
assert_eq!(stale_model_suggestion("claude-haiku-4-5-20251001"), None);
}
#[test]
fn stale_model_suggestion_future_version_fails_open() {
assert_eq!(stale_model_suggestion("claude-opus-4-9"), None);
assert_eq!(stale_model_suggestion("claude-sonnet-6"), None);
}
#[test]
fn stale_model_suggestion_legacy_claude_3_returns_some() {
assert_eq!(
stale_model_suggestion("claude-3-opus"),
Some("claude-sonnet-5 or claude-haiku-4-5-20251001")
);
assert_eq!(
stale_model_suggestion("claude-3-5-sonnet"),
Some("claude-sonnet-5 or claude-haiku-4-5-20251001")
);
}
#[test]
fn stale_model_suggestion_non_claude_or_alias_fails_open() {
assert_eq!(stale_model_suggestion("gpt-4o"), None);
assert_eq!(stale_model_suggestion("claude-opus-latest"), None);
assert_eq!(stale_model_suggestion("claude-unknownfamily-9"), None);
}
#[test]
fn name_defaults_to_claude() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-5-20250929".into(), 1024);
assert_eq!(provider.name(), "claude");
}
#[test]
fn with_provider_name_overrides_name() {
let provider = ClaudeProvider::new("k".into(), "claude-sonnet-4-5-20250929".into(), 1024)
.with_provider_name("quality-claude");
assert_eq!(provider.name(), "quality-claude");
}
#[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 history = GatedPlainHistory::new(vec![ApiMessage {
role: "user",
content: "hello",
}]);
let body = RequestBody {
model: "claude-sonnet-4-5-20250929",
max_tokens: 1024,
system: None,
messages: &history,
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 history = GatedPlainHistory::new(vec![]);
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,
ttl: None,
}),
}]),
messages: &history,
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 history = GatedPlainHistory::new(vec![]);
let body = RequestBody {
model: "test",
max_tokens: 100,
system: None,
messages: &history,
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 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 request_body_serializes_with_stream_false_omits_stream() {
let history = GatedPlainHistory::new(vec![]);
let body = RequestBody {
model: "test",
max_tokens: 100,
system: None,
messages: &history,
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, TEST_CLAUDE_MODEL, None);
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.", TEST_CLAUDE_MODEL, None);
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, TEST_CLAUDE_MODEL, None);
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-8", None);
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-8", None);
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, TEST_CLAUDE_MODEL, None);
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, TEST_CLAUDE_MODEL, None);
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, TEST_CLAUDE_MODEL, None);
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, TEST_CLAUDE_MODEL, None);
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 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 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": {}}),
output_schema: None,
}];
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!({}),
output_schema: None,
}];
let tools_b = vec![ToolDefinition {
name: "read".into(),
description: "Read files".into(),
parameters: serde_json::json!({}),
output_schema: None,
}];
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"]
}),
output_schema: None,
}];
let cached = provider.get_or_build_api_tools(&tools);
let pretty = serde_json::to_string_pretty(&cached).unwrap();
insta::assert_snapshot!(pretty);
}
#[tokio::test]
async fn chat_with_tools_sends_correct_tool_fields() {
use crate::provider::ToolDefinition;
use crate::testing::claude_tool_use_response;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(claude_tool_use_response(
"read_file",
"toolu_1",
&serde_json::json!({"path": "/tmp/f"}),
))
.expect(1)
.mount(&server)
.await;
let provider = ClaudeProvider::new("test-key".into(), "claude-test".into(), 256)
.with_api_url(format!("{}/v1/messages", server.uri()));
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"]}),
output_schema: None,
}];
let messages = vec![Message::from_legacy(Role::User, "read /tmp/f")];
let result = provider.chat_with_tools(&messages, &tools).await;
assert!(result.is_ok(), "request must succeed: {result:?}");
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
let sent_tools = body["tools"].as_array().expect("tools array present");
assert_eq!(sent_tools.len(), 1);
assert_eq!(sent_tools[0]["name"], "read_file");
assert_eq!(sent_tools[0]["description"], "Read a file from disk");
assert!(sent_tools[0]["input_schema"].is_object());
assert_eq!(sent_tools[0]["input_schema"]["type"], "object");
}
#[tokio::test]
async fn chat_with_tools_no_prefill_strips_trailing_assistant_over_wire() {
use crate::provider::ToolDefinition;
use crate::testing::claude_tool_use_response;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(claude_tool_use_response(
"read_file",
"toolu_1",
&serde_json::json!({"path": "a.rs"}),
))
.expect(1)
.mount(&server)
.await;
let provider = ClaudeProvider::new("test-key".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_api_url(format!("{}/v1/messages", server.uri()));
let tools = vec![ToolDefinition {
name: "read_file".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}}),
output_schema: None,
}];
let messages = vec![
Message::from_legacy(Role::User, "hello"),
Message::from_legacy(Role::Assistant, "trailing"),
];
let result = provider.chat_with_tools(&messages, &tools).await;
assert!(result.is_ok(), "request must succeed: {result:?}");
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.last().and_then(|m| m["role"].as_str()),
Some("user"),
"no-prefill gate must strip the trailing assistant message from the wire request: {body}"
);
}
#[tokio::test]
async fn chat_with_tools_stream_no_prefill_strips_trailing_assistant_over_wire() {
use crate::provider::ToolDefinition;
use crate::testing::claude_tool_use_sse_response;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(claude_tool_use_sse_response(
"toolu_1",
"read_file",
r#"{"path":"a.rs"}"#,
))
.expect(1)
.mount(&server)
.await;
let provider = ClaudeProvider::new("test-key".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_api_url(format!("{}/v1/messages", server.uri()));
let tools = vec![ToolDefinition {
name: "read_file".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}}),
output_schema: None,
}];
let messages = vec![
Message::from_legacy(Role::User, "hello"),
Message::from_legacy(Role::Assistant, "trailing"),
];
let result = provider.chat_with_tools_stream(&messages, &tools).await;
assert!(result.is_ok(), "request must succeed: {:?}", result.err());
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.last().and_then(|m| m["role"].as_str()),
Some("user"),
"no-prefill gate must strip the trailing assistant message from the wire request: {body}"
);
}
#[tokio::test]
async fn chat_typed_no_prefill_strips_trailing_assistant_over_wire() {
use crate::testing::claude_tool_use_response;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema, PartialEq)]
struct TypedOut {
value: String,
}
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(claude_tool_use_response(
"submit_TypedOut",
"toolu_1",
&serde_json::json!({"value": "ok"}),
))
.expect(1)
.mount(&server)
.await;
let provider = ClaudeProvider::new("test-key".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_api_url(format!("{}/v1/messages", server.uri()));
let messages = vec![
Message::from_legacy(Role::User, "hello"),
Message::from_legacy(Role::Assistant, "trailing"),
];
let result: Result<TypedOut, _> = provider.chat_typed(&messages).await;
assert!(result.is_ok(), "request must succeed: {result:?}");
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.last().and_then(|m| m["role"].as_str()),
Some("user"),
"no-prefill gate must strip the trailing assistant message from the wire request: {body}"
);
}
#[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": {}}),
output_schema: None,
},
ToolDefinition {
name: "tool_b".into(),
description: "Second tool".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
},
];
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": {}}),
output_schema: None,
}];
let tools_v2 = vec![
ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
},
ToolDefinition {
name: "browse".into(),
description: "Browse a URL".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
},
];
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": TEST_CLAUDE_MODEL,
"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 thinking_capability_opus_4_8_prefers_effort() {
let cap = thinking_capability("claude-opus-4-8");
assert!(cap.prefers_effort);
assert!(!cap.needs_interleaved_beta);
}
#[test]
fn thinking_capability_opus_4_7_prefers_effort() {
let cap = thinking_capability("claude-opus-4-7");
assert!(cap.prefers_effort);
}
#[test]
fn thinking_capability_sonnet_5_prefers_effort_no_interleaved_beta() {
let cap = thinking_capability("claude-sonnet-5");
assert!(cap.prefers_effort);
assert!(!cap.needs_interleaved_beta);
}
#[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-8".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-8".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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 8000,
})
.unwrap();
assert_eq!(provider.max_tokens, 32_000);
}
#[test]
fn set_thinking_none_restores_base_max_tokens_after_enable() {
let mut provider = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024);
provider
.set_thinking(Some(ThinkingConfig::Extended {
budget_tokens: 8000,
}))
.unwrap();
assert!(provider.max_tokens >= MIN_MAX_TOKENS_WITH_THINKING);
provider.set_thinking(None).unwrap();
assert_eq!(
provider.max_tokens, 1024,
"max_tokens must be restored to the construction-time baseline, not left at the floor"
);
assert!(provider.thinking.is_none());
}
#[test]
fn set_thinking_enable_disable_enable_is_idempotent() {
let mut provider = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024);
for budget in [8000, 12_000, 4000] {
provider
.set_thinking(Some(ThinkingConfig::Extended {
budget_tokens: budget,
}))
.unwrap();
assert_eq!(provider.max_tokens, MIN_MAX_TOKENS_WITH_THINKING);
provider.set_thinking(None).unwrap();
assert_eq!(provider.max_tokens, 1024);
}
}
#[test]
fn set_thinking_out_of_range_budget_rejected() {
let mut provider = ClaudeProvider::new("k".into(), "m".into(), 32_000);
let err = provider
.set_thinking(Some(ThinkingConfig::Extended { budget_tokens: 0 }))
.unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test]
fn set_thinking_construction_parity_with_with_thinking() {
let via_with_thinking = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 8000,
})
.unwrap();
let mut via_set_thinking = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024);
via_set_thinking
.set_thinking(Some(ThinkingConfig::Extended {
budget_tokens: 8000,
}))
.unwrap();
assert_eq!(via_with_thinking.max_tokens, via_set_thinking.max_tokens);
assert_eq!(via_with_thinking.thinking, via_set_thinking.thinking);
}
#[test]
fn current_thinking_budget_reflects_extended_config() {
let mut provider = ClaudeProvider::new("k".into(), "m".into(), 32_000);
assert!(provider.current_thinking_budget().is_none());
provider
.set_thinking(Some(ThinkingConfig::Extended {
budget_tokens: 8000,
}))
.unwrap();
assert_eq!(provider.current_thinking_budget(), Some(8000));
assert!(provider.current_reasoning_effort().is_none());
}
#[test]
fn current_reasoning_effort_reflects_adaptive_config() {
let mut provider = ClaudeProvider::new("k".into(), "m".into(), 32_000);
provider
.set_thinking(Some(ThinkingConfig::Adaptive {
effort: Some(ThinkingEffort::High),
}))
.unwrap();
assert_eq!(provider.current_reasoning_effort().as_deref(), Some("high"));
assert!(provider.current_thinking_budget().is_none());
}
#[test]
fn current_thinking_budget_and_effort_none_when_disabled() {
let provider = ClaudeProvider::new("k".into(), "m".into(), 32_000);
assert!(provider.current_thinking_budget().is_none());
assert!(provider.current_reasoning_effort().is_none());
}
#[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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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_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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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": {}}),
output_schema: None,
},
ToolDefinition {
name: "beta".into(),
description: "Second".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
},
ToolDefinition {
name: "gamma".into(),
description: "Third".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
},
];
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": {}}),
output_schema: None,
}];
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(TEST_CLAUDE_MODEL), 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-8"), 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-8", None);
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-8", None);
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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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"
);
}
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(), TEST_CLAUDE_MODEL.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"]
}),
output_schema: None,
}];
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(), TEST_CLAUDE_MODEL.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"}}}),
output_schema: None,
}];
let tools_v2 = vec![ToolDefinition {
name: "tool".into(),
description: "desc".into(),
parameters: serde_json::json!({"type": "object", "properties": {"b": {"type": "number"}}}),
output_schema: None,
}];
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"}),
output_schema: None,
}];
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 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-8".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 with thinking"
);
}
#[test]
fn build_request_opus_4_8_no_thinking_strips_trailing_assistant() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-8".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!(
msgs.last()
.and_then(|m| m["role"].as_str())
.is_none_or(|r| r != "assistant"),
"trailing assistant message must be stripped for Opus 4.8 regardless of thinking state"
);
}
#[test]
fn build_request_sonnet_5_no_thinking_strips_trailing_assistant() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-5".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!(
msgs.last()
.and_then(|m| m["role"].as_str())
.is_none_or(|r| r != "assistant"),
"trailing assistant message must be stripped for Sonnet 5 regardless of thinking state"
);
}
#[test]
fn build_request_sonnet_4_6_thinking_strips_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!(
msgs.last()
.and_then(|m| m["role"].as_str())
.is_none_or(|r| r != "assistant"),
"Sonnet 4.6 must strip trailing assistant messages regardless of thinking state"
);
}
#[test]
fn build_request_sonnet_4_6_no_thinking_strips_trailing_assistant() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-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!(
msgs.last()
.and_then(|m| m["role"].as_str())
.is_none_or(|r| r != "assistant"),
"Sonnet 4.6 must strip trailing assistant messages even without thinking configured"
);
}
#[test]
fn build_request_opus_4_6_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"),
"Opus 4.6 keeps trailing assistant message when thinking is disabled"
);
}
#[test]
fn no_prefill_true_for_opus_4_6_with_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let (thinking_param, _, _) = provider.build_thinking_param();
assert!(provider.no_prefill(thinking_param.as_ref()));
}
#[test]
fn no_prefill_false_for_opus_4_6_without_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-6".into(), 32_000);
let (thinking_param, _, _) = provider.build_thinking_param();
assert!(!provider.no_prefill(thinking_param.as_ref()));
}
#[test]
fn no_prefill_true_for_sonnet_4_6_with_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 32_000)
.with_thinking(ThinkingConfig::Extended {
budget_tokens: 5_000,
})
.unwrap();
let (thinking_param, _, _) = provider.build_thinking_param();
assert!(provider.no_prefill(thinking_param.as_ref()));
}
#[test]
fn no_prefill_true_for_sonnet_4_6_without_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-sonnet-4-6".into(), 32_000);
let (thinking_param, _, _) = provider.build_thinking_param();
assert!(provider.no_prefill(thinking_param.as_ref()));
}
#[test]
fn strip_trailing_assistant_structured_removes_only_trailing_run() {
let mut messages = vec![
StructuredApiMessage {
role: "user".into(),
content: StructuredContent::Text("hi".into()),
},
StructuredApiMessage {
role: "assistant".into(),
content: StructuredContent::Text("mid".into()),
},
StructuredApiMessage {
role: "user".into(),
content: StructuredContent::Text("follow-up".into()),
},
StructuredApiMessage {
role: "assistant".into(),
content: StructuredContent::Text("trailing".into()),
},
];
ClaudeProvider::strip_trailing_assistant_structured(true, &mut messages);
assert_eq!(
messages.len(),
3,
"only the trailing assistant run is stripped"
);
assert_eq!(messages.last().unwrap().role, "user");
}
#[test]
fn strip_trailing_assistant_structured_noop_when_gate_false() {
let mut messages = vec![StructuredApiMessage {
role: "assistant".into(),
content: StructuredContent::Text("trailing".into()),
}];
ClaudeProvider::strip_trailing_assistant_structured(false, &mut messages);
assert_eq!(
messages.len(),
1,
"no_prefill=false must never strip messages"
);
}
#[test]
fn strip_trailing_assistant_plain_removes_only_trailing_run() {
let mut messages = vec![
ApiMessage {
role: "user",
content: "hi",
},
ApiMessage {
role: "assistant",
content: "trailing",
},
];
ClaudeProvider::strip_trailing_assistant_plain(true, &mut messages);
assert_eq!(messages.len(), 1);
assert_eq!(messages.last().unwrap().role, "user");
}
#[test]
fn debug_request_json_tools_branch_strips_trailing_assistant_for_opus_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-8".into(), 32_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let messages = vec![
Message::from_legacy(Role::User, "hello"),
Message::from_legacy(Role::Assistant, "world"),
];
let tools = vec![ToolDefinition {
name: "read_file".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
}];
let body = provider.debug_request_json(&messages, &tools, false);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.len(),
1,
"only the trailing assistant message should be stripped, not the whole history"
);
assert_eq!(
msgs[0]["role"].as_str(),
Some("user"),
"tool-use requests must strip trailing assistant messages for Opus with thinking"
);
}
#[test]
fn debug_request_json_tools_branch_keeps_trailing_assistant_without_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-6".into(), 32_000);
let messages = vec![
Message::from_legacy(Role::User, "hello"),
Message::from_legacy(Role::Assistant, "world"),
];
let tools = vec![ToolDefinition {
name: "read_file".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
}];
let body = provider.debug_request_json(&messages, &tools, false);
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 debug_request_json_image_branch_strips_trailing_assistant_for_opus_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-8".into(), 32_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let messages = vec![
Message::from_parts(
Role::User,
vec![MessagePart::Image(Box::new(ImageData {
data: vec![1, 2, 3],
mime_type: "image/png".into(),
}))],
),
Message::from_legacy(Role::Assistant, "world"),
];
let body = provider.debug_request_json(&messages, &[], false);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.len(),
1,
"only the trailing assistant message should be stripped, not the whole history"
);
assert_eq!(
msgs[0]["role"].as_str(),
Some("user"),
"vision requests must strip trailing assistant messages for Opus with thinking"
);
}
#[test]
fn debug_request_json_plain_branch_strips_trailing_assistant_for_opus_thinking() {
let provider = ClaudeProvider::new("key".into(), "claude-opus-4-8".into(), 32_000)
.with_thinking(ThinkingConfig::Adaptive { effort: None })
.unwrap();
let messages = vec![
Message::from_legacy(Role::User, "hello"),
Message::from_legacy(Role::Assistant, "world"),
];
let body = provider.debug_request_json(&messages, &[], false);
let msgs = body["messages"].as_array().unwrap();
assert_eq!(
msgs.len(),
1,
"only the trailing assistant message should be stripped, not the whole history"
);
assert_eq!(
msgs[0]["role"].as_str(),
Some("user"),
"plain (no tools, no image) requests must strip trailing assistant messages too"
);
}
#[test]
fn build_request_opus_4_6_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"),
"Opus 4.6 must still strip trailing assistant messages when thinking is enabled"
);
}
#[test]
fn thinking_capability_sonnet_4_6_rejects_prefill() {
let cap = thinking_capability("claude-sonnet-4-6-20250514");
assert!(cap.rejects_prefill);
}
#[test]
fn thinking_capability_opus_4_6_does_not_reject_prefill_unconditionally() {
let cap = thinking_capability("claude-opus-4-6");
assert!(!cap.rejects_prefill);
}
#[test]
fn thinking_capability_opus_4_7_rejects_prefill() {
let cap = thinking_capability("claude-opus-4-7");
assert!(cap.rejects_prefill);
}
#[test]
fn thinking_capability_opus_4_8_rejects_prefill() {
let cap = thinking_capability("claude-opus-4-8");
assert!(cap.rejects_prefill);
}
#[test]
fn thinking_capability_sonnet_5_rejects_prefill() {
let cap = thinking_capability("claude-sonnet-5");
assert!(cap.rejects_prefill);
}
#[test]
fn server_compaction_disabled_by_default() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1024);
assert!(!provider.server_compaction_enabled());
}
#[test]
fn with_server_compaction_enables_flag() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.into(), 1024);
assert!(provider.take_compaction_summary().is_none());
}
#[test]
fn take_compaction_summary_returns_and_clears() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.into(), 1024);
assert!(provider.context_management().is_none());
}
#[test]
fn context_management_present_when_enabled() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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(), TEST_CLAUDE_MODEL.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 handle_compact_beta_rejection_sets_flags_and_returns_true_on_first_rejection() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_server_compaction(true);
let mut retried = false;
let handled = provider.handle_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
"unknown beta: compact-2026-01-12",
&mut retried,
"typed",
);
assert!(handled, "must return true on first observed rejection");
assert!(retried, "must flip the retry-once flag");
assert!(
provider.is_server_compaction_rejected(),
"must permanently disable server-side compaction"
);
}
#[test]
fn handle_compact_beta_rejection_returns_false_when_already_retried() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_server_compaction(true);
let mut retried = true;
let handled = provider.handle_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
"unknown beta: compact-2026-01-12",
&mut retried,
"",
);
assert!(!handled, "must not retry a second time");
assert!(
!provider.is_server_compaction_rejected(),
"must not touch the rejection flag once retry budget is exhausted"
);
}
#[test]
fn handle_compact_beta_rejection_returns_false_for_non_rejection_error() {
let provider = ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_server_compaction(true);
let mut retried = false;
let handled = provider.handle_compact_beta_rejection(
reqwest::StatusCode::BAD_REQUEST,
r#"{"error":{"message":"max_tokens: field required"}}"#,
&mut retried,
"streaming",
);
assert!(
!handled,
"unrelated 400s must fall through to normal error handling"
);
assert!(!retried);
assert!(!provider.is_server_compaction_rejected());
}
#[test]
fn output_schema_forwarding_enabled_appends_hint() {
use zeph_common::types::ToolDefinition;
let tool = ToolDefinition {
name: "my_tool".into(),
description: "Base description".into(),
parameters: serde_json::json!({"type": "object"}),
output_schema: Some(serde_json::json!({"type": "string"})),
};
let provider = crate::claude::ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1000)
.with_output_schema_forwarding(true, 512, usize::MAX);
let api_tools = provider.get_or_build_api_tools(&[tool]);
let desc = api_tools[0]["description"].as_str().unwrap();
assert!(
desc.contains("Expected output schema (JSON):"),
"hint must appear in description when forward_output_schema=true"
);
assert!(
!desc.contains("too large"),
"small schema must not trigger stub"
);
}
#[test]
fn output_schema_forwarding_disabled_no_hint() {
use zeph_common::types::ToolDefinition;
let tool = ToolDefinition {
name: "my_tool".into(),
description: "Base description".into(),
parameters: serde_json::json!({"type": "object"}),
output_schema: Some(serde_json::json!({"type": "string"})),
};
let provider = crate::claude::ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1000);
let api_tools = provider.get_or_build_api_tools(&[tool]);
let desc = api_tools[0]["description"].as_str().unwrap();
assert!(
!desc.contains("Expected output schema"),
"hint must NOT appear when forward_output_schema=false"
);
}
#[test]
fn output_schema_forwarding_truncates_large_schema() {
use zeph_common::types::ToolDefinition;
let large_schema = serde_json::json!({"description": "x".repeat(600)});
let tool = ToolDefinition {
name: "big_tool".into(),
description: "Base".into(),
parameters: serde_json::json!({"type": "object"}),
output_schema: Some(large_schema),
};
let provider = crate::claude::ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1000)
.with_output_schema_forwarding(true, 64, usize::MAX);
let api_tools = provider.get_or_build_api_tools(&[tool]);
let desc = api_tools[0]["description"].as_str().unwrap();
assert!(
desc.contains("too large"),
"oversized schema must use stub message"
);
}
#[test]
fn tool_cache_key_sensitive_to_output_schema() {
use crate::claude::cache::tool_cache_key;
use zeph_common::types::ToolDefinition;
let base = ToolDefinition {
name: "t".into(),
description: "d".into(),
parameters: serde_json::json!({}),
output_schema: None,
};
let with_schema = ToolDefinition {
output_schema: Some(serde_json::json!({"type": "string"})),
..base.clone()
};
assert_ne!(
tool_cache_key(&[base]),
tool_cache_key(&[with_schema]),
"cache key must differ when output_schema changes"
);
}
#[test]
fn tool_cache_key_sensitive_to_description() {
use crate::claude::cache::tool_cache_key;
use zeph_common::types::ToolDefinition;
let base = ToolDefinition {
name: "t".into(),
description: "original".into(),
parameters: serde_json::json!({}),
output_schema: None,
};
let changed = ToolDefinition {
description: "changed".into(),
..base.clone()
};
assert_ne!(
tool_cache_key(&[base]),
tool_cache_key(&[changed]),
"cache key must differ when description changes"
);
}
#[test]
fn test_claude_default_output_schema_hint_bytes_is_1024() {
use zeph_common::types::ToolDefinition;
let schema =
serde_json::json!({"type": "object", "properties": {"result": {"type": "string"}}});
let compact = serde_json::to_string(&schema).unwrap();
assert!(
compact.len() < 1024,
"test schema must be under 1024 bytes for this assertion to be meaningful"
);
let tool = ToolDefinition {
name: "do_something".into(),
description: "Do something".into(),
parameters: serde_json::json!({"type": "object"}),
output_schema: Some(schema),
};
let provider = crate::claude::ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1000)
.with_output_schema_forwarding(true, 1024, usize::MAX);
let api_tools = provider.get_or_build_api_tools(&[tool]);
let desc = api_tools[0]["description"].as_str().unwrap();
assert!(
desc.contains("Expected output schema"),
"schema under 1024 bytes must be forwarded with default budget"
);
assert!(
!desc.contains("too large"),
"schema under 1024 bytes must not trigger stub with default budget"
);
}
#[test]
fn test_claude_stub_used_when_schema_exceeds_1024_bytes() {
use zeph_common::types::ToolDefinition;
let large_schema = serde_json::json!({"description": "y".repeat(1100)});
let tool = ToolDefinition {
name: "large_tool".into(),
description: "Base".into(),
parameters: serde_json::json!({"type": "object"}),
output_schema: Some(large_schema),
};
let provider = crate::claude::ClaudeProvider::new("key".into(), TEST_CLAUDE_MODEL.into(), 1000)
.with_output_schema_forwarding(true, 1024, usize::MAX);
let api_tools = provider.get_or_build_api_tools(&[tool]);
let desc = api_tools[0]["description"].as_str().unwrap();
assert!(
desc.contains("too large"),
"schema exceeding 1024 bytes must use stub with default budget"
);
}
#[test]
fn cache_ttl_ephemeral_requires_no_beta() {
assert!(!CacheTtl::Ephemeral.requires_beta());
}
#[test]
fn cache_ttl_one_hour_requires_beta() {
assert!(CacheTtl::OneHour.requires_beta());
}
#[test]
fn cache_ttl_ephemeral_serializes_without_ttl_field() {
use super::cache::build_cache_control;
let cc = build_cache_control(None);
let v = serde_json::to_value(&cc).unwrap();
assert_eq!(v, serde_json::json!({"type": "ephemeral"}));
}
#[test]
fn cache_ttl_one_hour_serializes_with_ttl_field() {
use super::cache::build_cache_control;
let cc = build_cache_control(Some(CacheTtl::OneHour));
let v = serde_json::to_value(&cc).unwrap();
assert_eq!(v, serde_json::json!({"type": "ephemeral", "ttl": "1h"}));
}
#[test]
fn cache_ttl_deserialize_rejects_unknown_string() {
let result = serde_json::from_str::<CacheTtl>("\"30m\"");
assert!(
result.is_err(),
"unknown TTL string must fail deserialization"
);
}
#[test]
fn cache_ttl_one_hour_toml_round_trip() {
use serde::Deserialize;
let v = toml::Value::String("1h".into());
let ttl = CacheTtl::deserialize(v).unwrap();
assert_eq!(ttl, CacheTtl::OneHour);
}
#[test]
fn cache_ttl_ephemeral_toml_round_trip() {
use serde::Deserialize;
let v = toml::Value::String("ephemeral".into());
let ttl = CacheTtl::deserialize(v).unwrap();
assert_eq!(ttl, CacheTtl::Ephemeral);
}
#[test]
fn beta_header_includes_extended_cache_ttl_for_one_hour() {
let p = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_prompt_cache_ttl(Some(CacheTtl::OneHour));
let header = p.beta_header(false).unwrap_or_default();
assert!(
header.contains("extended-cache-ttl-2025-04-25"),
"beta header must include extended-cache-ttl-2025-04-25 for OneHour TTL"
);
}
#[test]
fn beta_header_omits_extended_cache_ttl_for_ephemeral() {
let p = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024);
let header = p.beta_header(false).unwrap_or_default();
assert!(
!header.contains("extended-cache-ttl-2025-04-25"),
"beta header must not include extended-cache-ttl for default ephemeral TTL"
);
}
#[test]
fn tool_cache_control_uses_typed_path_for_one_hour() {
use zeph_common::types::ToolDefinition;
let tool = ToolDefinition {
name: "my_tool".into(),
description: "desc".into(),
parameters: serde_json::json!({"type": "object"}),
output_schema: None,
};
let provider = ClaudeProvider::new("k".into(), TEST_CLAUDE_MODEL.into(), 1024)
.with_prompt_cache_ttl(Some(CacheTtl::OneHour));
let api_tools = provider.get_or_build_api_tools(&[tool]);
let cc = &api_tools[0]["cache_control"];
assert_eq!(cc["type"], "ephemeral");
assert_eq!(cc["ttl"], "1h");
}
#[test]
fn split_system_into_blocks_propagates_one_hour_ttl() {
use super::cache::split_system_into_blocks;
let system = "A".repeat(20_000);
let blocks = split_system_into_blocks(&system, TEST_CLAUDE_MODEL, Some(CacheTtl::OneHour));
let cached = blocks
.iter()
.find(|b| b.cache_control.is_some())
.expect("at least one cached block");
let cc = cached.cache_control.as_ref().unwrap();
assert_eq!(cc.ttl, Some(CacheTtl::OneHour));
}
#[test]
fn apply_cache_breakpoint_propagates_one_hour_ttl() {
use super::cache::apply_cache_breakpoint;
use super::types::{AnthropicContentBlock, StructuredContent};
let mut chat = vec![
StructuredApiMessage {
role: "user".into(),
content: StructuredContent::Text("first".into()),
},
StructuredApiMessage {
role: "assistant".into(),
content: StructuredContent::Text("reply".into()),
},
StructuredApiMessage {
role: "user".into(),
content: StructuredContent::Text("second".into()),
},
];
apply_cache_breakpoint(&mut chat, Some(CacheTtl::OneHour));
let breakpoint_msg = chat.iter().find(|m| {
if let StructuredContent::Blocks(blocks) = &m.content {
blocks.iter().any(|b| {
if let AnthropicContentBlock::Text { cache_control, .. } = b {
cache_control
.as_ref()
.is_some_and(|cc| cc.ttl == Some(CacheTtl::OneHour))
} else {
false
}
})
} else {
false
}
});
assert!(
breakpoint_msg.is_some(),
"breakpoint message must carry 1h TTL"
);
}