Skip to main content

llm_universal/
anthropic.rs

1use async_trait::async_trait;
2use reqwest::header::HeaderValue;
3use serde_json::{json, Value};
4
5use crate::{
6    send_json, ChatRequest, ChatResponse, LlmError, LlmProvider, Message, Part, Result, Role,
7    StopReason, Usage, DEFAULT_MAX_TOKENS,
8};
9
10/// Value of the `anthropic-version` header.
11const API_VERSION: &str = "2023-06-01";
12
13/// Client for the Anthropic Messages API (`POST {base_url}/v1/messages`).
14#[derive(Clone)]
15pub struct AnthropicProvider {
16    client: reqwest::Client,
17    api_key: String,
18    base_url: String,
19}
20
21impl std::fmt::Debug for AnthropicProvider {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("AnthropicProvider")
24            .field("api_key", &"<redacted>")
25            .field("base_url", &self.base_url)
26            .finish()
27    }
28}
29
30impl AnthropicProvider {
31    pub fn new(api_key: impl Into<String>) -> Self {
32        Self {
33            client: crate::http_client(),
34            api_key: api_key.into(),
35            base_url: "https://api.anthropic.com".to_string(),
36        }
37    }
38
39    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
40        self.base_url = base_url.into().trim_end_matches('/').to_string();
41        self
42    }
43
44    /// Uses your own `reqwest::Client`, for example to share a connection pool or set a proxy.
45    pub fn with_client(mut self, client: reqwest::Client) -> Self {
46        self.client = client;
47        self
48    }
49
50    fn build_body(req: &ChatRequest) -> Value {
51        // Anthropic has no `system` role in `messages`: system messages are merged into `system`.
52        let mut system_parts: Vec<String> = req.system.iter().cloned().collect();
53        let mut messages = Vec::new();
54        for message in &req.messages {
55            if message.role == Role::System {
56                system_parts.push(message.text_content());
57            } else {
58                messages.push(to_anthropic_message(message));
59            }
60        }
61
62        let mut body = json!({
63            "model": req.model,
64            "max_tokens": req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
65            "messages": messages,
66        });
67        if !system_parts.is_empty() {
68            body["system"] = json!(system_parts.join("\n\n"));
69        }
70        if !req.tools.is_empty() {
71            let tools: Vec<Value> = req
72                .tools
73                .iter()
74                .map(|t| {
75                    json!({
76                        "name": t.name,
77                        "description": t.description,
78                        "input_schema": t.parameters,
79                    })
80                })
81                .collect();
82            body["tools"] = json!(tools);
83        }
84        if let Some(temperature) = req.temperature {
85            body["temperature"] = json!(temperature);
86        }
87        body
88    }
89}
90
91fn to_anthropic_message(message: &Message) -> Value {
92    // Tool results are `tool_result` blocks inside a `user` turn.
93    let role = if message.role == Role::Assistant {
94        "assistant"
95    } else {
96        "user"
97    };
98    // The API rejects empty text blocks, so they are skipped.
99    let content: Vec<Value> = message
100        .parts
101        .iter()
102        .filter(|p| !matches!(p, Part::Text { text } if text.is_empty()))
103        .map(|p| match p {
104            Part::Text { text } => json!({ "type": "text", "text": text }),
105            Part::ToolCall {
106                id,
107                name,
108                arguments,
109            } => {
110                json!({ "type": "tool_use", "id": id, "name": name, "input": arguments })
111            }
112            Part::ToolResult {
113                call_id,
114                content,
115                is_error,
116            } => json!({
117                "type": "tool_result",
118                "tool_use_id": call_id,
119                "content": content,
120                "is_error": is_error,
121            }),
122        })
123        .collect();
124    json!({ "role": role, "content": content })
125}
126
127fn parse_response(v: &Value) -> Result<ChatResponse> {
128    let blocks = v["content"]
129        .as_array()
130        .ok_or_else(|| LlmError::InvalidResponse("missing field `content`".into()))?;
131
132    let mut parts = Vec::new();
133    for block in blocks {
134        match block["type"].as_str() {
135            Some("text") => {
136                let text = block["text"].as_str().unwrap_or_default();
137                if !text.is_empty() {
138                    parts.push(Part::Text {
139                        text: text.to_string(),
140                    });
141                }
142            }
143            Some("tool_use") => parts.push(Part::ToolCall {
144                id: block["id"].as_str().unwrap_or_default().to_string(),
145                name: block["name"].as_str().unwrap_or_default().to_string(),
146                arguments: block["input"].clone(),
147            }),
148            // Thinking and other blocks are not part of the normalized response.
149            _ => {}
150        }
151    }
152
153    let stop_reason = match v["stop_reason"].as_str() {
154        Some("end_turn") | Some("stop_sequence") => StopReason::EndTurn,
155        Some("tool_use") => StopReason::ToolUse,
156        Some("max_tokens") | Some("model_context_window_exceeded") => StopReason::MaxTokens,
157        Some("pause_turn") => StopReason::Paused,
158        Some("refusal") => StopReason::Refusal,
159        other => StopReason::Other(other.unwrap_or("unknown").to_string()),
160    };
161
162    let tokens =
163        |field: &str| u32::try_from(v["usage"][field].as_u64().unwrap_or(0)).unwrap_or(u32::MAX);
164    let usage = Usage {
165        input_tokens: tokens("input_tokens"),
166        output_tokens: tokens("output_tokens"),
167    };
168
169    Ok(ChatResponse {
170        model: v["model"].as_str().unwrap_or_default().to_string(),
171        message: Message::new(Role::Assistant, parts),
172        stop_reason,
173        usage,
174    })
175}
176
177#[async_trait]
178impl LlmProvider for AnthropicProvider {
179    async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
180        // Anthropic allows tool names up to 128 characters.
181        crate::validate(
182            "AnthropicProvider",
183            &self.api_key,
184            true,
185            &self.base_url,
186            128,
187            req,
188        )?;
189        let mut api_key = HeaderValue::from_str(&self.api_key).map_err(|_| {
190            LlmError::InvalidArgument(
191                "AnthropicProvider: api_key contains characters not allowed in an HTTP header"
192                    .into(),
193            )
194        })?;
195        api_key.set_sensitive(true);
196
197        let url = format!("{}/v1/messages", self.base_url);
198        let builder = self
199            .client
200            .post(url)
201            .header("x-api-key", api_key)
202            .header("anthropic-version", API_VERSION)
203            .json(&Self::build_body(req));
204        let body = send_json(builder).await?;
205        parse_response(&body)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::Tool;
213
214    #[test]
215    fn moves_system_and_maps_tool_results() {
216        let req = ChatRequest::new("claude-opus-5-5")
217            .system("sys")
218            .tool(Tool::new("f", "d", json!({"type": "object"})))
219            .message(Message::user("hi"))
220            .message(Message::tool_result("toolu_1", "42", false));
221        let body = AnthropicProvider::build_body(&req);
222        assert_eq!(body["system"], "sys");
223        assert_eq!(body["max_tokens"], DEFAULT_MAX_TOKENS);
224        assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
225        assert_eq!(body["messages"][1]["role"], "user");
226        assert_eq!(body["messages"][1]["content"][0]["type"], "tool_result");
227        assert_eq!(body["messages"][1]["content"][0]["tool_use_id"], "toolu_1");
228    }
229
230    #[test]
231    fn parses_tool_use_response() {
232        let v = json!({
233            "model": "claude-opus-5-5",
234            "content": [
235                { "type": "text", "text": "Let me check." },
236                { "type": "tool_use", "id": "toolu_1", "name": "f", "input": { "x": 1 } }
237            ],
238            "stop_reason": "tool_use",
239            "usage": { "input_tokens": 3, "output_tokens": 9 }
240        });
241        let resp = parse_response(&v).unwrap();
242        assert_eq!(resp.stop_reason, StopReason::ToolUse);
243        assert_eq!(resp.text(), "Let me check.");
244        assert_eq!(resp.tool_calls()[0].2["x"], 1);
245        assert_eq!(resp.usage.input_tokens, 3);
246    }
247
248    #[test]
249    fn skips_empty_text_blocks() {
250        let msg = Message::new(
251            Role::Assistant,
252            vec![Part::Text {
253                text: String::new(),
254            }],
255        );
256        assert_eq!(to_anthropic_message(&msg)["content"], json!([]));
257    }
258
259    #[test]
260    fn debug_redacts_api_key() {
261        let debug = format!("{:?}", AnthropicProvider::new("sk-ant-secret"));
262        assert!(!debug.contains("sk-ant-secret"));
263    }
264}