238 lines
10 KiB
Rust
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));
|
|
}
|