focus/crates/focus-providers/tests/anthropic_tests.rs

238 lines
10 KiB
Rust

//! Anthropic provider 的录制/回放集成测试。
//! Recorded/replayed integration tests for the Anthropic provider.
mod common;
use focus_core::model::*;
use focus_core::provider::{ProviderRequest, StreamEvent};
use focus_providers::anthropic::AnthropicProvider;
use focus_providers::config::ProviderConfig;
use focus_transport::TransportError;
use std::sync::Arc;
fn provider(mock: common::MockTransport) -> AnthropicProvider {
AnthropicProvider::with_transport(ProviderConfig::new("sk-test"), Arc::new(mock))
}
fn request() -> ProviderRequest {
ProviderRequest {
model: "claude-sonnet-4".into(),
system_prompt: "You are helpful.".into(),
messages: vec![Message::user_text("hello")],
tools: focus_json::JsonValue::arr(),
max_tokens: Some(1024),
temperature: None,
}
}
/// 事件序列化简:只保留 (标签, 文本/名称) 供断言。
/// Event-sequence digest: keep only (label, text/name) for assertions.
fn digest(events: &[StreamEvent]) -> Vec<(&'static str, String)> {
events
.iter()
.map(|e| match e {
StreamEvent::Start { .. } => ("start", String::new()),
StreamEvent::TextStart { .. } => ("text_start", String::new()),
StreamEvent::TextDelta { delta, .. } => ("text_delta", delta.clone()),
StreamEvent::TextEnd { .. } => ("text_end", String::new()),
StreamEvent::ThinkingStart { .. } => ("thinking_start", String::new()),
StreamEvent::ThinkingDelta { delta, .. } => ("thinking_delta", delta.clone()),
StreamEvent::ThinkingEnd { .. } => ("thinking_end", String::new()),
StreamEvent::ToolCallStart { .. } => ("tool_start", String::new()),
StreamEvent::ToolCallDelta { delta, .. } => ("tool_delta", delta.clone()),
StreamEvent::ToolCallEnd { tool_call, .. } => ("tool_end", tool_call.name.clone()),
StreamEvent::Done { message } => ("done", message.stop_reason.as_str().into()),
StreamEvent::Error { error } => {
("error", error.error_message.clone().unwrap_or_default())
}
})
.collect()
}
#[test]
fn streams_text_response() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":4,\"cache_read_input_tokens\":2}}}\n\n\
event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n\
event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":6}}\n\n\
event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
);
let provider = provider(mock);
let events = common::collect(&provider, &request());
let d = digest(&events);
assert_eq!(d[0].0, "start");
assert_eq!(d[1].0, "text_start");
assert_eq!(d[2], ("text_delta", "Hello".into()));
assert_eq!(d[3], ("text_delta", " world".into()));
assert_eq!(d[4].0, "text_end");
assert_eq!(d[5].0, "done");
assert_eq!(d[5].1, "stop");
// 最终消息内容与用量。
// Final message content and usage.
let done = events
.iter()
.find_map(|e| match e {
StreamEvent::Done { message } => Some(message),
_ => None,
})
.expect("done event");
let text = done
.content
.iter()
.find_map(|c| c.as_text())
.expect("text block");
assert_eq!(text.text, "Hello world");
assert_eq!(done.usage.input_tokens, 10);
assert_eq!(done.usage.output_tokens, 6);
assert_eq!(done.usage.cache_write_tokens, 4);
assert_eq!(done.usage.cache_read_tokens, 2);
assert_eq!(done.stop_reason, StopReason::Stop);
}
#[test]
fn streams_thinking_then_text() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5}}}\n\n\
event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"sig-1\"}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me think\"}}\n\n\
event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Answer: 42\"}}\n\n\
event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n\
event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
);
let provider = provider(mock);
let events = common::collect(&provider, &request());
let d = digest(&events);
assert!(d.contains(&("thinking_start", String::new())));
assert!(d.contains(&("thinking_delta", "Let me think".into())));
assert!(d.contains(&("thinking_end", String::new())));
let done = events
.iter()
.find_map(|e| match e {
StreamEvent::Done { message } => Some(message),
_ => None,
})
.unwrap();
assert_eq!(done.content.len(), 2);
match &done.content[0] {
ContentBlock::Thinking(t) => {
assert_eq!(t.thinking, "Let me think");
assert_eq!(t.signature.as_deref(), Some("sig-1"));
}
other => panic!("expected thinking block, got {:?}", other),
}
let text = done.content[1].as_text().expect("text block");
assert_eq!(text.text, "Answer: 42");
}
#[test]
fn streams_tool_use_with_partial_json() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":7}}}\n\n\
event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"read\",\"input\":{}}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"a\"}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"bc.txt\\\"}\"}}\n\n\
event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":9}}\n\n\
event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
);
let provider = provider(mock);
let events = common::collect(&provider, &request());
let done = events
.iter()
.find_map(|e| match e {
StreamEvent::Done { message } => Some(message),
_ => None,
})
.unwrap();
assert_eq!(done.stop_reason, StopReason::ToolUse);
let tc = done.content[0].as_tool_call().expect("tool call");
assert_eq!(tc.id, "toolu_1");
assert_eq!(tc.name, "read");
assert_eq!(tc.arguments.get_str("path"), Some("abc.txt"));
// ToolCallEnd 携带最终的 tool call。
// ToolCallEnd carries the final tool call.
let end = events
.iter()
.find_map(|e| match e {
StreamEvent::ToolCallEnd { tool_call, .. } => Some(tool_call),
_ => None,
})
.unwrap();
assert_eq!(end.arguments.get_str("path"), Some("abc.txt"));
}
#[test]
fn encodes_api_error_event() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n",
);
let provider = provider(mock);
let events = common::collect(&provider, &request());
match events.last().unwrap() {
StreamEvent::Error { error } => {
assert_eq!(error.stop_reason, StopReason::Error);
let msg = error.error_message.clone().unwrap_or_default();
assert!(msg.contains("overloaded"), "got: {}", msg);
}
other => panic!("expected error event, got {:?}", other),
}
}
#[test]
fn encodes_http_error() {
let mut mock = common::MockTransport::new();
mock.push_error(TransportError::Http {
status: 429,
body: "rate limited".into(),
});
let provider = provider(mock);
let events = common::collect(&provider, &request());
match events.last().unwrap() {
StreamEvent::Error { error } => {
let msg = error.error_message.clone().unwrap_or_default();
assert!(msg.contains("429"), "got: {}", msg);
}
other => panic!("expected error event, got {:?}", other),
}
}
#[test]
fn request_is_well_formed() {
let mut mock = common::MockTransport::new();
mock.push_body("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n");
let provider = provider(mock.clone());
let _ = common::collect(&provider, &request());
let req = mock.last_request();
assert_eq!(req.host, "api.anthropic.com");
assert_eq!(req.path, "/v1/messages");
let headers: Vec<(String, String)> = req.headers.clone();
assert!(headers
.iter()
.any(|(k, v)| k == "x-api-key" && v == "sk-test"));
assert!(headers
.iter()
.any(|(k, v)| k == "anthropic-version" && v == "2023-06-01"));
let body: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
assert_eq!(body.get_str("model"), Some("claude-sonnet-4"));
assert_eq!(body.get_bool("stream"), Some(true));
}