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

377 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Anthropic provider 的录制/回放集成测试。
//! Recorded/replayed integration tests for the Anthropic provider.
mod common;
use focus_core::model::*;
use focus_core::provider::{ProviderRequest, StreamEvent};
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
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));
assert_eq!(body.get_str("system"), Some("You are helpful."));
}
/// 请求体必须完整携带会话思考块签名回传、tool_use 块、多个 tool_result
/// 合并进一条 user 消息、工具翻译成 input_schema回归原为内联私有函数测试
/// The request body must carry the full transcript: thinking signatures,
/// tool_use blocks, multiple tool_results merged into one user message, and
/// tools translated to input_schema (regression: was an inline private-fn test).
#[test]
fn request_body_carries_full_transcript() {
#[derive(Debug)]
struct EchoTool;
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes text"
}
fn parameters(&self) -> focus_json::JsonValue {
let mut o = focus_json::JsonValue::obj();
o.insert("type", "object".into()).ok();
let mut props = focus_json::JsonValue::obj();
props
.insert("text", focus_json::JsonValue::Str("the text".into()))
.ok();
o.insert("properties", props).ok();
o
}
fn effects(&self) -> ToolEffects {
ToolEffects::READ
}
fn execute(
&self,
_id: &str,
_args: &focus_json::JsonValue,
_u: Option<&ToolUpdateSink>,
) -> Result<ToolResult, focus_core::CoreError> {
Ok(ToolResult::text("ok"))
}
}
let rich_request = ProviderRequest {
model: "claude-sonnet-4".into(),
system_prompt: "You are helpful.".into(),
messages: vec![
Message::user_text("hi"),
Message::Assistant(AssistantMessage {
content: vec![
ContentBlock::Thinking(ThinkingContent {
thinking: "hmm".into(),
signature: Some("sig".into()),
redacted: false,
}),
ContentBlock::ToolCall(ToolCall {
id: "call_1".into(),
name: "echo".into(),
arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(),
}),
],
model: "claude-sonnet-4".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}),
Message::ToolResult(ToolResultMessage {
tool_call_id: "call_1".into(),
tool_name: "echo".into(),
content: vec![ContentBlock::text("ok")],
details: focus_json::JsonValue::obj(),
is_error: false,
timestamp: 0,
}),
],
tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(),
max_tokens: Some(1024),
temperature: Some(0.5),
};
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, &rich_request);
let req = mock.last_request();
let body: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
// 工具翻译成 input_schema。
// Tools translated with input_schema.
let tools = body.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("name"), Some("echo"));
assert!(tools[0].get("input_schema").is_some());
// 思考块带签名回传。
// Thinking blocks replayed with their signature.
let messages = body.get_arr("messages").unwrap();
let assistant = &messages[1];
let content = assistant.get_arr("content").unwrap();
assert_eq!(content[0].get_str("type"), Some("thinking"));
assert_eq!(content[0].get_str("signature"), Some("sig"));
assert_eq!(content[1].get_str("type"), Some("tool_use"));
assert_eq!(content[1].get_str("id"), Some("call_1"));
// tool_result 落在一条 user 消息里。
// tool_result lands inside one user message.
let tool_msg = &messages[2];
assert_eq!(tool_msg.get_str("role"), Some("user"));
let blocks = tool_msg.get_arr("content").unwrap();
assert_eq!(blocks[0].get_str("type"), Some("tool_result"));
assert_eq!(blocks[0].get_str("tool_use_id"), Some("call_1"));
}
/// stop_reason 映射max_tokens → Length回归原为内联私有函数测试
/// Stop-reason mapping: max_tokens → Length (regression: was an inline
/// private-fn test).
#[test]
fn maps_max_tokens_stop_reason() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{}}}\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\":\"partial\"}}\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\":\"max_tokens\"},\"usage\":{}}\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,
})
.expect("done event");
assert_eq!(done.stop_reason, StopReason::Length);
}