//! 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)); }