1use async_trait::async_trait;
2use eventsource_stream::Eventsource;
3use futures_core::Stream;
4use futures_util::StreamExt;
5use reqwest::Client;
6use serde_json::{Value, json};
7use std::pin::Pin;
8
9use super::{LlmCapabilities, LlmClient, ReasoningConfig, StreamChunk, UsageInfo};
10use crate::types::{AgentError, AgentResult, ChatMessage, ImageAttachment, ResponseFormat};
11
12pub struct AnthropicClient {
13 api_key: String,
14 model: String,
15 base_url: String,
16 client: Client,
17}
18
19impl AnthropicClient {
20 pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
21 Self::new_with_config(
22 api_key,
23 model,
24 base_url,
25 crate::llm::LlmClientConfig::default(),
26 )
27 }
28
29 pub fn new_with_config(
30 api_key: String,
31 model: String,
32 base_url: Option<String>,
33 config: crate::llm::LlmClientConfig,
34 ) -> Self {
35 let client = Client::builder()
36 .connect_timeout(config.connect_timeout)
37 .read_timeout(config.request_timeout)
38 .pool_max_idle_per_host(config.pool_max_idle_per_host)
39 .pool_idle_timeout(config.pool_idle_timeout)
40 .build()
41 .unwrap_or_else(|e| {
42 tracing::warn!(error = %e, "Failed to build reqwest client with custom config, falling back to default");
43 Client::new()
44 });
45 Self {
46 api_key,
47 model,
48 base_url: base_url.unwrap_or_else(|| "https://api.anthropic.com".to_string()),
49 client,
50 }
51 }
52
53 fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<Value>) {
54 let mut system_prompt: Option<String> = None;
55 let mut result: Vec<Value> = Vec::new();
56
57 for msg in messages {
58 match msg {
59 ChatMessage::System { content, .. } => {
60 system_prompt = Some(content.clone());
61 }
62 ChatMessage::User {
63 content, images, ..
64 } => {
65 let mut content_parts: Vec<Value> = Vec::new();
66 content_parts.push(json!({"type": "text", "text": content}));
67 for img in images {
68 match img {
69 ImageAttachment::Url { url, detail: _ } => {
70 content_parts.push(json!({
71 "type": "image",
72 "source": {
73 "type": "url",
74 "url": url,
75 }
76 }));
77 }
78 ImageAttachment::Base64 {
79 data,
80 media_type,
81 detail: _,
82 } => {
83 let mime = media_type.as_deref().unwrap_or("image/jpeg");
84 content_parts.push(json!({
85 "type": "image",
86 "source": {
87 "type": "base64",
88 "media_type": mime,
89 "data": data,
90 }
91 }));
92 }
93 }
94 }
95 result.push(json!({
96 "role": "user",
97 "content": content_parts,
98 }));
99 }
100 ChatMessage::Assistant {
101 content,
102 reasoning_content: _,
103 tool_calls,
104 } => {
105 let mut parts: Vec<Value> = Vec::new();
106 if let Some(text) = content
107 && !text.is_empty()
108 {
109 parts.push(json!({"type": "text", "text": text}));
110 }
111 if let Some(tc) = tool_calls {
112 for t in tc {
113 let input: Value =
114 serde_json::from_str(&t.arguments).unwrap_or(Value::Null);
115 parts.push(json!({
116 "type": "tool_use",
117 "id": t.id,
118 "name": t.name,
119 "input": input,
120 }));
121 }
122 }
123 if !parts.is_empty() {
124 result.push(json!({"role": "assistant", "content": parts}));
125 }
126 }
127 ChatMessage::Tool {
128 tool_call_id,
129 content,
130 } => {
131 result.push(json!({
132 "role": "user",
133 "content": [{
134 "type": "tool_result",
135 "tool_use_id": tool_call_id,
136 "content": content,
137 }]
138 }));
139 }
140 ChatMessage::Custom { role: _, data } => {
141 result.push(json!({
143 "role": "user",
144 "content": [{
145 "type": "text",
146 "text": data.to_string(),
147 }]
148 }));
149 }
150 }
151 }
152
153 (system_prompt, result)
154 }
155
156 fn convert_tools(tools: &[Value]) -> Vec<Value> {
157 tools
158 .iter()
159 .filter_map(|tool| {
160 let func = tool.get("function")?;
161 let name = func.get("name")?.as_str()?;
162 let description = func
163 .get("description")
164 .and_then(Value::as_str)
165 .unwrap_or("");
166 let input_schema = func
167 .get("parameters")
168 .cloned()
169 .unwrap_or_else(|| json!({"type": "object"}));
170 Some(json!({
171 "name": name,
172 "description": description,
173 "input_schema": input_schema,
174 }))
175 })
176 .collect()
177 }
178
179 fn build_body(
180 messages: &[ChatMessage],
181 tools: &[Value],
182 model: &str,
183 reasoning: Option<&ReasoningConfig>,
184 ) -> Value {
185 let (system_prompt, anthropic_messages) = Self::convert_messages(messages);
186 let anthropic_tools = Self::convert_tools(tools);
187
188 let mut body = json!({
189 "model": model,
190 "max_tokens": 8192,
191 "messages": anthropic_messages,
192 });
193
194 if !anthropic_tools.is_empty()
195 && let Some(obj) = body.as_object_mut()
196 {
197 obj.insert("tools".to_string(), json!(anthropic_tools));
198 }
199
200 if let Some(system) = system_prompt
201 && let Some(obj) = body.as_object_mut()
202 {
203 obj.insert("system".to_string(), json!(system));
204 }
205
206 if let Some(config) = reasoning {
207 if config.enabled == Some(true) || config.budget_tokens.is_some() {
208 let mut thinking = serde_json::Map::new();
209 thinking.insert("type".to_string(), json!("enabled"));
210 if let Some(budget) = config.budget_tokens {
211 thinking.insert("budget_tokens".to_string(), json!(budget));
212 }
213 if let Some(obj) = body.as_object_mut() {
214 obj.insert("thinking".to_string(), Value::Object(thinking));
215 }
216 } else if config.enabled == Some(false) {
217 let mut thinking = serde_json::Map::new();
218 thinking.insert("type".to_string(), json!("disabled"));
219 if let Some(obj) = body.as_object_mut() {
220 obj.insert("thinking".to_string(), Value::Object(thinking));
221 }
222 }
223 }
224
225 body
226 }
227
228 fn parse_sse(data_str: &str, event_type: &str) -> AgentResult<StreamChunk> {
229 if data_str.is_empty() {
230 return Ok(StreamChunk::Text(String::new()));
231 }
232
233 let data: Value = serde_json::from_str(data_str)
234 .map_err(|e| AgentError::json(format!("Anthropic SSE JSON: {e}")))?;
235
236 match event_type {
237 "message_start" => {
238 let input_tokens = data
239 .get("message")
240 .and_then(|m| m.get("usage"))
241 .and_then(|u| u.get("input_tokens"))
242 .and_then(Value::as_u64)
243 .map(|v| v as u32);
244 let output_tokens = data
245 .get("message")
246 .and_then(|m| m.get("usage"))
247 .and_then(|u| u.get("output_tokens"))
248 .and_then(Value::as_u64)
249 .map(|v| v as u32);
250 Ok(StreamChunk::Usage(UsageInfo {
251 prompt_tokens: input_tokens,
252 completion_tokens: output_tokens,
253 total_tokens: None,
254 }))
255 }
256 "content_block_start" => {
257 let cb = data.get("content_block");
258 let idx = data.get("index").and_then(Value::as_u64).unwrap_or(0);
259 if let Some(cb) = cb
260 && cb.get("type").and_then(Value::as_str) == Some("tool_use")
261 {
262 let id = cb
263 .get("id")
264 .and_then(Value::as_str)
265 .unwrap_or("")
266 .to_string();
267 let name = cb
268 .get("name")
269 .and_then(Value::as_str)
270 .unwrap_or("")
271 .to_string();
272 return Ok(StreamChunk::ToolCall(json!({
273 "delta": {
274 "tool_calls": [{
275 "index": idx,
276 "id": if id.is_empty() { Value::Null } else { json!(id) },
277 "function": {
278 "name": name,
279 "arguments": "",
280 }
281 }]
282 }
283 })));
284 }
285 Ok(StreamChunk::Text(String::new()))
286 }
287 "content_block_delta" => {
288 let delta = data.get("delta");
289 let idx = data.get("index").and_then(Value::as_u64).unwrap_or(0);
290 if let Some(d) = delta {
291 match d.get("type").and_then(Value::as_str) {
292 Some("text_delta") => {
293 let text = d
294 .get("text")
295 .and_then(Value::as_str)
296 .unwrap_or("")
297 .to_string();
298 Ok(StreamChunk::Text(text))
299 }
300 Some("input_json_delta") => {
301 let partial = d
302 .get("partial_json")
303 .and_then(Value::as_str)
304 .unwrap_or("")
305 .to_string();
306 Ok(StreamChunk::ToolCall(json!({
307 "delta": {
308 "tool_calls": [{
309 "index": idx,
310 "function": {
311 "arguments": partial,
312 }
313 }]
314 }
315 })))
316 }
317 Some("thinking_delta") => {
318 let thinking = d
319 .get("thinking")
320 .and_then(Value::as_str)
321 .unwrap_or("")
322 .to_string();
323 Ok(StreamChunk::Thought(thinking))
324 }
325 _ => Ok(StreamChunk::Text(String::new())),
326 }
327 } else {
328 Ok(StreamChunk::Text(String::new()))
329 }
330 }
331 "content_block_stop" => Ok(StreamChunk::Text(String::new())),
332 "message_delta" => {
333 let output_tokens = data
334 .get("usage")
335 .and_then(|u| u.get("output_tokens"))
336 .and_then(Value::as_u64)
337 .map(|v| v as u32);
338 Ok(StreamChunk::Usage(UsageInfo {
339 prompt_tokens: None,
340 completion_tokens: output_tokens,
341 total_tokens: None,
342 }))
343 }
344 "message_stop" => {
345 let finish_reason = data
346 .get("message")
347 .and_then(|m| m.get("stop_reason"))
348 .and_then(Value::as_str)
349 .map(String::from);
350 Ok(StreamChunk::Stop { finish_reason })
351 }
352 "ping" => Ok(StreamChunk::Text(String::new())),
353 _ => Ok(StreamChunk::Text(String::new())),
354 }
355 }
356}
357
358#[async_trait]
359impl LlmClient for AnthropicClient {
360 async fn chat(
361 &self,
362 messages: &[ChatMessage],
363 tools: &[Value],
364 reasoning: Option<&ReasoningConfig>,
365 _response_format: Option<&ResponseFormat>,
366 ) -> AgentResult<Value> {
367 let url = format!("{}/v1/messages", self.base_url);
368 let body = Self::build_body(messages, tools, &self.model, reasoning);
369 tracing::debug!(model = %self.model, url = %url, body = %serde_json::to_string_pretty(&body).unwrap_or_default(), "Anthropic chat request");
370
371 let response = self
372 .client
373 .post(&url)
374 .header("x-api-key", &self.api_key)
375 .header("anthropic-version", "2023-06-01")
376 .header("Content-Type", "application/json")
377 .json(&body)
378 .send()
379 .await
380 .map_err(|e| AgentError::llm(format!("HTTP request failed: {e}")))?;
381
382 let status = response.status();
383 let res_json: Value = response
384 .json()
385 .await
386 .map_err(|e| AgentError::json(format!("Response JSON parse failed: {e}")))?;
387
388 if !status.is_success() {
389 let err_msg = res_json
390 .get("error")
391 .and_then(|e| e.get("message"))
392 .and_then(Value::as_str)
393 .unwrap_or("unknown error");
394 tracing::warn!(status = %status, error = %err_msg, "Anthropic API non-success");
395 return Err(AgentError::LlmApi {
396 message: err_msg.to_string(),
397 });
398 }
399
400 tracing::debug!(status = %status, "Anthropic chat response received");
401 Ok(res_json)
402 }
403
404 async fn chat_stream(
405 &self,
406 messages: &[ChatMessage],
407 tools: &[Value],
408 reasoning: Option<&ReasoningConfig>,
409 _response_format: Option<&ResponseFormat>,
410 ) -> AgentResult<Pin<Box<dyn Stream<Item = AgentResult<StreamChunk>> + Send>>> {
411 let url = format!("{}/v1/messages", self.base_url);
412 let mut body = Self::build_body(messages, tools, &self.model, reasoning);
413
414 if let Some(obj) = body.as_object_mut() {
415 obj.insert("stream".to_string(), json!(true));
416 }
417 tracing::debug!(model = %self.model, url = %url, body = %serde_json::to_string_pretty(&body).unwrap_or_default(), "Anthropic chat_stream request");
418
419 let response = self
420 .client
421 .post(&url)
422 .header("x-api-key", &self.api_key)
423 .header("anthropic-version", "2023-06-01")
424 .header("Content-Type", "application/json")
425 .json(&body)
426 .send()
427 .await
428 .map_err(|e| AgentError::llm(format!("HTTP request failed: {e}")))?;
429
430 if !response.status().is_success() {
431 let status = response.status();
432 let err_text = response
433 .text()
434 .await
435 .map_err(|e| AgentError::llm(format!("Failed to read error response: {e}")))?;
436 tracing::warn!(%status, error = %err_text, "Anthropic API stream non-success");
437 return Err(AgentError::LlmApi { message: err_text });
438 }
439
440 let stream = response
441 .bytes_stream()
442 .eventsource()
443 .filter_map(|event| async move {
444 match event {
445 Ok(ref ev) if ev.event == "error" => {
446 let err_msg = ev.data.clone();
447 Some(Err(AgentError::LlmApi { message: err_msg }))
448 }
449 Ok(ev) => {
450 let event_type = ev.event.as_str();
453 match Self::parse_sse(&ev.data, event_type) {
454 Ok(chunk) => Some(Ok(chunk)),
455 Err(e) => Some(Err(e)),
456 }
457 }
458 Err(e) => Some(Err(AgentError::LlmStream(format!("SSE Stream error: {e}")))),
459 }
460 });
461
462 Ok(Box::pin(stream))
463 }
464
465 fn capabilities(&self) -> LlmCapabilities {
466 LlmCapabilities {
467 supports_streaming: true,
468 supports_tools: true,
469 supports_vision: true,
470 supports_thinking: true,
471 max_context_tokens: Some(200_000),
472 max_output_tokens: Some(8_192),
473 }
474 }
475
476 fn model_name(&self) -> &str {
477 &self.model
478 }
479}
480
481#[async_trait]
482impl super::StreamClient for AnthropicClient {
483 async fn stream(
484 &self,
485 messages: &[crate::types::ChatMessage],
486 tools: &[serde_json::Value],
487 reasoning: Option<&super::ReasoningConfig>,
488 response_format: Option<&crate::types::ResponseFormat>,
489 ) -> crate::types::AgentResult<
490 std::pin::Pin<
491 Box<
492 dyn futures_core::Stream<Item = crate::types::AgentResult<super::StreamChunk>>
493 + Send,
494 >,
495 >,
496 > {
497 <Self as super::LlmClient>::chat_stream(self, messages, tools, reasoning, response_format)
498 .await
499 }
500
501 fn capabilities(&self) -> super::LlmCapabilities {
502 <Self as super::LlmClient>::capabilities(self)
503 }
504
505 fn model_name(&self) -> &str {
506 <Self as super::LlmClient>::model_name(self)
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::types::{ChatMessage, ImageAttachment};
514 use futures_util::TryStreamExt;
515 use wiremock::matchers::{method, path};
516 use wiremock::{Mock, MockServer, ResponseTemplate};
517
518 #[test]
521 fn convert_messages_handles_all_variants() {
522 let msgs = vec![
523 ChatMessage::system("sys"),
524 ChatMessage::user("hi"),
525 ChatMessage::user_with_images(
526 "pic",
527 vec![
528 ImageAttachment::Url {
529 url: "http://x/a.png".into(),
530 detail: None,
531 },
532 ImageAttachment::Base64 {
533 data: "abc".into(),
534 media_type: Some("image/png".into()),
535 detail: None,
536 },
537 ],
538 ),
539 ChatMessage::assistant("hi back"),
540 ChatMessage::assistant_tool_call("tc1", "echo", "{\"x\":1}"),
541 ChatMessage::tool("tc1", "done"),
542 ChatMessage::Custom {
543 role: "artifact".into(),
544 data: serde_json::json!({"x": 1}),
545 },
546 ];
547
548 let (sys, out) = AnthropicClient::convert_messages(&msgs);
549 assert_eq!(sys.as_deref(), Some("sys"));
550 assert_eq!(out.len(), 6);
551
552 assert_eq!(
553 out[0],
554 serde_json::json!({"role": "user", "content": [{"type": "text", "text": "hi"}]})
555 );
556
557 assert_eq!(out[1]["role"], "user");
558 assert_eq!(
559 out[1]["content"][0],
560 serde_json::json!({"type": "text", "text": "pic"})
561 );
562 assert_eq!(out[1]["content"][1]["type"], "image");
563 assert_eq!(out[1]["content"][1]["source"]["type"], "url");
564 assert_eq!(out[1]["content"][2]["source"]["type"], "base64");
565 assert_eq!(out[1]["content"][2]["source"]["media_type"], "image/png");
566
567 assert_eq!(
568 out[2],
569 serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "hi back"}]})
570 );
571
572 assert_eq!(out[3]["role"], "assistant");
573 assert_eq!(out[3]["content"][0]["type"], "tool_use");
574 assert_eq!(out[3]["content"][0]["id"], "tc1");
575 assert_eq!(out[3]["content"][0]["name"], "echo");
576 assert_eq!(out[3]["content"][0]["input"], serde_json::json!({"x": 1}));
577
578 assert_eq!(out[4]["role"], "user");
579 assert_eq!(out[4]["content"][0]["type"], "tool_result");
580 assert_eq!(out[4]["content"][0]["tool_use_id"], "tc1");
581 assert_eq!(out[4]["content"][0]["content"], "done");
582
583 assert_eq!(out[5]["role"], "user");
584 assert_eq!(out[5]["content"][0]["type"], "text");
585 assert_eq!(out[5]["content"][0]["text"], "{\"x\":1}");
586 }
587
588 #[test]
589 fn convert_tools_maps_openai_to_anthropic() {
590 let tools = vec![
591 serde_json::json!({"type": "function", "function": {"name": "echo", "description": "echo back", "parameters": {"type": "object", "properties": {}}}}),
592 serde_json::json!({"function": {"name": "bare"}}),
593 serde_json::json!({"type": "function"}),
594 serde_json::json!({"function": {"description": "no name"}}),
595 ];
596 let out = AnthropicClient::convert_tools(&tools);
597 assert_eq!(out.len(), 2);
598 assert_eq!(out[0]["name"], "echo");
599 assert_eq!(out[0]["description"], "echo back");
600 assert_eq!(out[0]["input_schema"]["type"], "object");
601 assert_eq!(out[1]["name"], "bare");
602 assert_eq!(out[1]["description"], "");
603 assert_eq!(
604 out[1]["input_schema"],
605 serde_json::json!({"type": "object"})
606 );
607 }
608
609 #[test]
610 fn build_body_basic() {
611 let body =
612 AnthropicClient::build_body(&[ChatMessage::user("hi")], &[], "claude-sonnet", None);
613 assert_eq!(body["model"], "claude-sonnet");
614 assert_eq!(body["max_tokens"], 8192);
615 assert_eq!(body["messages"][0]["role"], "user");
616 assert!(body.get("system").is_none());
617 assert!(body.get("tools").is_none());
618 }
619
620 #[test]
621 fn build_body_includes_system_and_tools() {
622 let msgs = vec![ChatMessage::system("sys"), ChatMessage::user("hi")];
623 let tools = vec![serde_json::json!({"function": {"name": "echo"}})];
624 let body = AnthropicClient::build_body(&msgs, &tools, "m", None);
625 assert_eq!(body["system"], "sys");
626 assert_eq!(body["tools"][0]["name"], "echo");
627 }
628
629 #[test]
630 fn build_body_reasoning_variants() {
631 let msgs = vec![ChatMessage::user("hi")];
632
633 let rc = ReasoningConfig {
634 enabled: Some(true),
635 budget_tokens: None,
636 effort: None,
637 };
638 let body = AnthropicClient::build_body(&msgs, &[], "m", Some(&rc));
639 assert_eq!(body["thinking"]["type"], "enabled");
640
641 let rc = ReasoningConfig {
642 enabled: None,
643 budget_tokens: Some(500),
644 effort: None,
645 };
646 let body = AnthropicClient::build_body(&msgs, &[], "m", Some(&rc));
647 assert_eq!(body["thinking"]["type"], "enabled");
648 assert_eq!(body["thinking"]["budget_tokens"], 500);
649
650 let rc = ReasoningConfig {
651 enabled: Some(false),
652 budget_tokens: None,
653 effort: None,
654 };
655 let body = AnthropicClient::build_body(&msgs, &[], "m", Some(&rc));
656 assert_eq!(body["thinking"]["type"], "disabled");
657 }
658
659 #[test]
660 fn parse_sse_empty_data() {
661 let c = AnthropicClient::parse_sse("", "message_start").unwrap();
662 assert!(matches!(c, StreamChunk::Text(t) if t.is_empty()));
663 }
664
665 #[test]
666 fn parse_sse_message_start_usage() {
667 let c = AnthropicClient::parse_sse(
668 r#"{"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":5}}}"#,
669 "message_start",
670 )
671 .unwrap();
672 assert!(
673 matches!(c, StreamChunk::Usage(u) if u.prompt_tokens == Some(10) && u.completion_tokens == Some(5))
674 );
675 }
676
677 #[test]
678 fn parse_sse_content_block_start_tool_use() {
679 let c = AnthropicClient::parse_sse(
680 r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"echo"}}"#,
681 "content_block_start",
682 )
683 .unwrap();
684 match c {
685 StreamChunk::ToolCall(v) => {
686 assert_eq!(v["delta"]["tool_calls"][0]["index"], 0);
687 assert_eq!(v["delta"]["tool_calls"][0]["id"], "toolu_1");
688 assert_eq!(v["delta"]["tool_calls"][0]["function"]["name"], "echo");
689 }
690 other => panic!("expected ToolCall, got {other:?}"),
691 }
692 }
693
694 #[test]
695 fn parse_sse_content_block_start_text() {
696 let c = AnthropicClient::parse_sse(
697 r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
698 "content_block_start",
699 )
700 .unwrap();
701 assert!(matches!(c, StreamChunk::Text(t) if t.is_empty()));
702 }
703
704 #[test]
705 fn parse_sse_content_block_delta_variants() {
706 let text = AnthropicClient::parse_sse(
707 r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}"#,
708 "content_block_delta",
709 )
710 .unwrap();
711 assert!(matches!(text, StreamChunk::Text(t) if t == "hello"));
712
713 let tj = AnthropicClient::parse_sse(
714 r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"x=1"}}"#,
715 "content_block_delta",
716 )
717 .unwrap();
718 match tj {
719 StreamChunk::ToolCall(v) => {
720 assert_eq!(v["delta"]["tool_calls"][0]["index"], 1);
721 assert_eq!(v["delta"]["tool_calls"][0]["function"]["arguments"], "x=1");
722 }
723 other => panic!("expected ToolCall, got {other:?}"),
724 }
725
726 let th = AnthropicClient::parse_sse(
727 r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}"#,
728 "content_block_delta",
729 )
730 .unwrap();
731 assert!(matches!(th, StreamChunk::Thought(t) if t == "hmm"));
732
733 let unk = AnthropicClient::parse_sse(
734 r#"{"type":"content_block_delta","index":0,"delta":{"type":"whatever"}}"#,
735 "content_block_delta",
736 )
737 .unwrap();
738 assert!(matches!(unk, StreamChunk::Text(t) if t.is_empty()));
739
740 let nd = AnthropicClient::parse_sse(
741 r#"{"type":"content_block_delta","index":0}"#,
742 "content_block_delta",
743 )
744 .unwrap();
745 assert!(matches!(nd, StreamChunk::Text(t) if t.is_empty()));
746 }
747
748 #[test]
749 fn parse_sse_message_delta_usage() {
750 let c = AnthropicClient::parse_sse(
751 r#"{"type":"message_delta","usage":{"output_tokens":12}}"#,
752 "message_delta",
753 )
754 .unwrap();
755 assert!(
756 matches!(c, StreamChunk::Usage(u) if u.completion_tokens == Some(12) && u.prompt_tokens.is_none())
757 );
758 }
759
760 #[test]
761 fn parse_sse_message_stop() {
762 let c = AnthropicClient::parse_sse(
763 r#"{"type":"message_stop","message":{"stop_reason":"end_turn"}}"#,
764 "message_stop",
765 )
766 .unwrap();
767 assert!(matches!(c, StreamChunk::Stop { finish_reason: Some(r) } if r == "end_turn"));
768
769 let c = AnthropicClient::parse_sse(r#"{"type":"message_stop"}"#, "message_stop").unwrap();
770 assert!(matches!(
771 c,
772 StreamChunk::Stop {
773 finish_reason: None
774 }
775 ));
776 }
777
778 #[test]
779 fn parse_sse_ping_unknown_and_invalid() {
780 assert!(matches!(
781 AnthropicClient::parse_sse(r#"{"type":"ping"}"#, "ping").unwrap(),
782 StreamChunk::Text(_)
783 ));
784 assert!(matches!(
785 AnthropicClient::parse_sse(r#"{}"#, "something_else").unwrap(),
786 StreamChunk::Text(_)
787 ));
788 assert!(AnthropicClient::parse_sse("not-json", "message_stop").is_err());
789 }
790
791 #[test]
792 fn capabilities_and_model_name() {
793 let c = AnthropicClient::new("k".into(), "claude-sonnet".into(), None);
794 let caps = c.capabilities();
795 assert!(caps.supports_streaming);
796 assert!(caps.supports_tools);
797 assert!(caps.supports_vision);
798 assert!(caps.supports_thinking);
799 assert_eq!(caps.max_context_tokens, Some(200_000));
800 assert_eq!(caps.max_output_tokens, Some(8_192));
801 assert_eq!(c.model_name(), "claude-sonnet");
802 }
803
804 #[tokio::test]
807 async fn chat_posts_and_parses_response() {
808 let server = MockServer::start().await;
809 Mock::given(method("POST"))
810 .and(path("/v1/messages"))
811 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
812 "id": "msg_1",
813 "type": "message",
814 "content": [{"type": "text", "text": "hello"}],
815 })))
816 .mount(&server)
817 .await;
818
819 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
820 let resp = client
821 .chat(&[ChatMessage::user("hi")], &[], None, None)
822 .await
823 .unwrap();
824 assert_eq!(resp["id"], "msg_1");
825 }
826
827 #[tokio::test]
828 async fn chat_returns_llm_api_error() {
829 let server = MockServer::start().await;
830 Mock::given(method("POST"))
831 .and(path("/v1/messages"))
832 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
833 "type": "error",
834 "error": {"type": "invalid_request_error", "message": "bad request"},
835 })))
836 .mount(&server)
837 .await;
838
839 let client = AnthropicClient::new("bad".into(), "claude-sonnet".into(), Some(server.uri()));
840 let resp = client
841 .chat(&[ChatMessage::user("hi")], &[], None, None)
842 .await;
843 assert!(resp.is_err());
844 }
845
846 #[tokio::test]
847 async fn chat_stream_parses_full_event_sequence() {
848 let server = MockServer::start().await;
849 let sse = concat!(
850 "event: message_start\n",
851 "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}}\n\n",
852 "event: content_block_start\n",
853 "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"echo\"}}\n\n",
854 "event: content_block_delta\n",
855 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"abc\"}}\n\n",
856 "event: content_block_delta\n",
857 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
858 "event: content_block_delta\n",
859 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n",
860 "event: message_delta\n",
861 "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":12}}\n\n",
862 "event: message_stop\n",
863 "data: {\"type\":\"message_stop\",\"message\":{\"stop_reason\":\"end_turn\"}}\n\n",
864 );
865 Mock::given(method("POST"))
866 .and(path("/v1/messages"))
867 .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
868 .mount(&server)
869 .await;
870
871 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
872 let stream = client
873 .chat_stream(&[ChatMessage::user("hi")], &[], None, None)
874 .await
875 .unwrap();
876 let chunks: Vec<StreamChunk> = stream.try_collect().await.unwrap();
877
878 assert!(
879 matches!(&chunks[0], StreamChunk::Usage(u) if u.prompt_tokens == Some(10) && u.completion_tokens == Some(5))
880 );
881 assert!(matches!(&chunks[1], StreamChunk::ToolCall(_)));
882 assert!(matches!(&chunks[2], StreamChunk::ToolCall(_)));
883 assert!(matches!(&chunks[3], StreamChunk::Text(t) if t == "hello"));
884 assert!(matches!(&chunks[4], StreamChunk::Thought(t) if t == "hmm"));
885 assert!(matches!(&chunks[5], StreamChunk::Usage(u) if u.completion_tokens == Some(12)));
886 assert!(
887 matches!(&chunks[6], StreamChunk::Stop { finish_reason: Some(r) } if r == "end_turn")
888 );
889 }
890
891 #[tokio::test]
892 async fn chat_stream_returns_error_on_error_event() {
893 let server = MockServer::start().await;
894 let sse = concat!(
895 "event: error\n",
896 "data: {\"type\":\"error\",\"error\":{\"message\":\"overloaded\"}}\n\n",
897 );
898 Mock::given(method("POST"))
899 .and(path("/v1/messages"))
900 .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
901 .mount(&server)
902 .await;
903
904 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
905 let stream = client
906 .chat_stream(&[ChatMessage::user("hi")], &[], None, None)
907 .await
908 .unwrap();
909 let result: Result<Vec<_>, _> = stream.try_collect().await;
910 assert!(result.is_err());
911 }
912
913 #[test]
916 fn stream_client_delegates_capabilities_and_model_name() {
917 let c = AnthropicClient::new("k".into(), "claude-sonnet".into(), None);
918 let caps = <AnthropicClient as crate::llm::StreamClient>::capabilities(&c);
919 assert!(caps.supports_streaming);
920 assert_eq!(
921 <AnthropicClient as crate::llm::StreamClient>::model_name(&c),
922 "claude-sonnet"
923 );
924 }
925
926 #[tokio::test]
927 async fn stream_client_stream_delegates_to_chat_stream() {
928 let server = MockServer::start().await;
929 let sse = concat!(
930 "event: content_block_delta\n",
931 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
932 );
933 Mock::given(method("POST"))
934 .and(path("/v1/messages"))
935 .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
936 .mount(&server)
937 .await;
938
939 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
940 let stream = <AnthropicClient as crate::llm::StreamClient>::stream(
941 &client,
942 &[ChatMessage::user("hi")],
943 &[],
944 None,
945 None,
946 )
947 .await
948 .unwrap();
949 let chunks: Vec<StreamChunk> = stream.try_collect().await.unwrap();
950 assert!(matches!(&chunks[0], StreamChunk::Text(t) if t == "hi"));
951 }
952
953 #[tokio::test]
954 async fn chat_errors_on_non_json_response() {
955 let server = MockServer::start().await;
956 Mock::given(method("POST"))
957 .and(path("/v1/messages"))
958 .respond_with(ResponseTemplate::new(200).set_body_string("not-json"))
959 .mount(&server)
960 .await;
961
962 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
963 let resp = client
964 .chat(&[ChatMessage::user("hi")], &[], None, None)
965 .await;
966 assert!(resp.is_err());
967 }
968
969 #[tokio::test]
970 async fn chat_stream_returns_error_on_non_success_status() {
971 let server = MockServer::start().await;
972 Mock::given(method("POST"))
973 .and(path("/v1/messages"))
974 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
975 .mount(&server)
976 .await;
977
978 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
979 let result = client
980 .chat_stream(&[ChatMessage::user("hi")], &[], None, None)
981 .await;
982 assert!(result.is_err());
983 }
984
985 #[tokio::test]
986 async fn chat_stream_wraps_parse_error_as_err() {
987 let server = MockServer::start().await;
988 let sse = concat!("event: message_stop\n", "data: not-json\n\n");
989 Mock::given(method("POST"))
990 .and(path("/v1/messages"))
991 .respond_with(ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream"))
992 .mount(&server)
993 .await;
994
995 let client = AnthropicClient::new("k".into(), "claude-sonnet".into(), Some(server.uri()));
996 let stream = client
997 .chat_stream(&[ChatMessage::user("hi")], &[], None, None)
998 .await
999 .unwrap();
1000 let result: Result<Vec<_>, _> = stream.try_collect().await;
1001 assert!(result.is_err());
1002 }
1003}