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
10const API_VERSION: &str = "2023-06-01";
12
13#[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 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 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 let role = if message.role == Role::Assistant {
94 "assistant"
95 } else {
96 "user"
97 };
98 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 _ => {}
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 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}