Skip to main content

agent_base/llm/
anthropic.rs

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            .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                    // Custom messages are passed as user-role with their data serialized.
142                    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                        // eventsource-stream normalizes an empty SSE `event:`
451                        // field to "message", so `ev.event` is never empty here.
452                        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    // ---- pure helpers ----
519
520    #[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    // ---- mock HTTP ----
805
806    #[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    // ── B2: adapter delegation + remaining HTTP/SSE edges ────────────────
914
915    #[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}