use locode_protocol::{ContentBlock, Message, ReasoningFormat, Role};
use locode_provider::anthropic::wire;
use locode_provider::anthropic::{ModelConfig, build_request, response_to_completion};
use locode_provider::{CacheHint, ConversationRequest, ProviderError, SamplingArgs, StopReason};
fn fixture(name: &str) -> wire::MessagesResponse {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_string());
let path = format!("{manifest_dir}/tests/fixtures/{name}");
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {path}: {e}"))
}
#[test]
fn tool_use_ids_preserved_verbatim() {
let completion = response_to_completion(fixture("tool_use_response.json")).expect("parses");
assert_eq!(completion.stop, StopReason::ToolUse);
assert!(completion.has_tool_calls());
let ids: Vec<_> = completion
.content
.iter()
.filter_map(|b| match b {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
assert_eq!(
ids,
vec!["toolu_01A09q90qw90lq917835lq9"],
"no sanitization"
);
}
#[test]
fn usage_maps_all_four_fields() {
let completion = response_to_completion(fixture("tool_use_response.json")).expect("parses");
assert_eq!(completion.usage.input_tokens, 1024);
assert_eq!(completion.usage.output_tokens, 76);
assert_eq!(completion.usage.cache_creation_tokens, Some(900));
assert_eq!(completion.usage.cache_read_tokens, Some(0));
}
#[test]
fn interleaved_thinking_signatures_round_trip_through_build() {
let completion =
response_to_completion(fixture("interleaved_thinking_response.json")).expect("parses");
assert_eq!(completion.content.len(), 5);
assert!(matches!(
&completion.content[3],
ContentBlock::Reasoning { format: ReasoningFormat::AnthropicRedacted, payload: Some(p), .. }
if p.as_str() == Some("EncryptedOpaquePayload==")
));
assert!(matches!(
&completion.content[0],
ContentBlock::Reasoning { signature: Some(sig), .. }
if sig.starts_with("EqQBCgIYAhIM1gbcDa9GJwZA")
));
assert!(matches!(
&completion.content[2],
ContentBlock::Reasoning { signature: Some(sig), .. }
if sig == "EqQBCgIYAhIMSecondSignature999AbCdEfGh"
));
assert_eq!(completion.usage.cache_read_tokens, Some(1800));
let req = ConversationRequest {
messages: vec![
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "summarize the repo".into(),
}],
},
Message {
role: Role::Assistant,
content: completion.content.clone(),
},
],
tools: vec![],
sampling_args: SamplingArgs::default(),
cache_hint: CacheHint::Off,
};
let cfg = ModelConfig::new("claude-sonnet-5", "https://api.anthropic.com", "k");
let built = build_request(&req, &cfg);
let json = serde_json::to_value(&built).expect("serializes");
let replayed = json["messages"][1]["content"].as_array().expect("blocks");
assert_eq!(replayed.len(), 5, "all five blocks replayed in order");
assert_eq!(replayed[0]["type"], "thinking");
assert_eq!(
replayed[0]["signature"],
"EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds"
);
assert_eq!(replayed[1]["type"], "tool_use");
assert_eq!(replayed[1]["id"], "toolu_staged_01");
assert_eq!(replayed[2]["type"], "thinking");
assert_eq!(
replayed[2]["signature"],
"EqQBCgIYAhIMSecondSignature999AbCdEfGh"
);
assert_eq!(replayed[3]["type"], "redacted_thinking");
assert_eq!(
replayed[3]["data"], "EncryptedOpaquePayload==",
"encrypted thinking replays verbatim"
);
assert_eq!(replayed[4]["type"], "tool_use");
assert_eq!(replayed[4]["id"], "toolu_staged_02");
}
#[test]
fn context_overflow_with_empty_content_is_terminal() {
let err = response_to_completion(fixture("context_overflow_response.json"))
.expect_err("empty overflow folds into the terminal error");
assert!(matches!(err, ProviderError::ContextOverflow));
assert!(!err.retryable());
}
fn response_with_stop(stop: Option<&str>) -> wire::MessagesResponse {
let stop_json = stop.map_or("null".to_string(), |s| format!("\"{s}\""));
serde_json::from_str(&format!(
r#"{{
"id": "msg_x", "type": "message", "role": "assistant",
"content": [{{"type": "text", "text": "hi"}}],
"model": "m", "stop_reason": {stop_json},
"usage": {{"input_tokens": 1, "output_tokens": 1}}
}}"#
))
.unwrap_or_else(|e| panic!("valid response json: {e}"))
}
#[test]
fn stop_reason_mapping_covers_known_and_unknown() {
let cases = [
("end_turn", StopReason::EndTurn),
("max_tokens", StopReason::MaxTokens),
("tool_use", StopReason::ToolUse),
("stop_sequence", StopReason::StopSequence),
("refusal", StopReason::Refusal),
("pause_turn", StopReason::PauseTurn),
(
"some_future_reason",
StopReason::Unknown("some_future_reason".into()),
),
];
for (raw, expected) in cases {
let completion =
response_to_completion(response_with_stop(Some(raw))).expect("never fails parse");
assert_eq!(completion.stop, expected, "stop_reason {raw}");
}
}
#[test]
fn overflow_with_partial_content_returns_the_completion() {
let completion =
response_to_completion(response_with_stop(Some("model_context_window_exceeded")))
.expect("partial content is returned");
assert_eq!(
completion.stop,
StopReason::Unknown("model_context_window_exceeded".into())
);
}
#[test]
fn refusal_is_a_normal_completion() {
let completion = response_to_completion(response_with_stop(Some("refusal"))).expect("ok");
assert_eq!(completion.stop, StopReason::Refusal);
}
#[test]
fn null_usage_counters_parse_as_zero() {
let resp: wire::MessagesResponse = serde_json::from_str(
r#"{
"id": "gen-x", "type": "message", "role": "assistant",
"content": [{"type": "text", "text": "OK."}],
"model": "openai/gpt-4o-mini", "stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 2,
"cache_creation_input_tokens": null,
"cache_read_input_tokens": 0}
}"#,
)
.unwrap_or_else(|e| panic!("null counters must not fail the parse: {e}"));
let completion = response_to_completion(resp).expect("ok");
assert_eq!(completion.usage.input_tokens, 10);
assert_eq!(
completion.usage.cache_creation_tokens, None,
"null → not reported (the honest N/A, not a fake zero)"
);
}
#[test]
fn missing_stop_reason_never_invents_one() {
let completion = response_to_completion(response_with_stop(None)).expect("ok");
assert!(matches!(completion.stop, StopReason::Unknown(_)));
}
#[test]
fn thinking_tokens_populate_reasoning_tokens_but_not_the_context_total() {
let resp: wire::MessagesResponse = serde_json::from_value(serde_json::json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 100,
"output_tokens": 40,
"output_tokens_details": {"thinking_tokens": 25}
}
}))
.expect("parses");
let completion = response_to_completion(resp).expect("completion");
assert_eq!(completion.usage.reasoning_tokens, Some(25));
assert_eq!(
completion.usage.context_tokens(),
140,
"input + output only — the 25 thinking tokens are already inside output_tokens' turn"
);
}
#[test]
fn a_missing_output_tokens_details_is_not_an_error() {
let resp: wire::MessagesResponse = serde_json::from_value(serde_json::json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-opus-4-8",
"content": [{"type": "text", "text": "hi"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5}
}))
.expect("parses without a breakdown");
let completion = response_to_completion(resp).expect("completion");
assert_eq!(completion.usage.reasoning_tokens, None);
}