1use axum::http::HeaderMap;
5use serde_json::{json, Value};
6
7use super::{
8 retry_after, status_error, AuthStyle, HttpCall, LlmError, Provider, Reply, Request, Stop,
9 ToolCall, Turn, Usage,
10};
11
12pub const BASE_URL: &str = "https://api.anthropic.com/v1";
14pub const API_VERSION: &str = "2026-10-21";
16pub const FALLBACK_BETA: &str = "server-side-fallback-2026-07-01";
18const FALLBACK_MODELS: &[&str] = &[
20 "claude-opus-5-5",
21 "claude-opus-5",
22 "claude-fable-5-1",
23 "claude-sonnet-5-5",
24];
25
26pub struct Anthropic;
28
29fn message(turn: &Turn) -> Value {
30 match turn {
31 Turn::User(text) => json!({ "role": "user", "content": text }),
32 Turn::Assistant { raw, .. } => json!({ "role": "assistant", "content": raw }),
33 Turn::ToolResults(results) => {
34 let blocks: Vec<Value> = results
36 .iter()
37 .map(|r| {
38 json!({
39 "type": "tool_result",
40 "tool_use_id": r.call_id,
41 "content": r.text,
42 "is_error": r.is_error,
43 })
44 })
45 .collect();
46 json!({ "role": "user", "content": blocks })
47 }
48 }
49}
50
51impl Provider for Anthropic {
52 fn name(&self) -> &'static str {
53 "anthropic"
54 }
55
56 fn auth(&self) -> AuthStyle {
57 AuthStyle::XApiKey
58 }
59
60 fn encode(&self, request: &Request) -> HttpCall {
61 let tools: Vec<Value> = request
62 .tools
63 .iter()
64 .map(|t| {
65 json!({
66 "name": t.name,
67 "description": t.description,
68 "input_schema": t.schema,
69 })
70 })
71 .collect();
72 let messages: Vec<Value> = request.turns.iter().map(message).collect();
73 let mut body = json!({
74 "model": request.model,
75 "max_tokens": request.max_tokens,
76 "system": request.system,
77 "tools": tools,
78 "messages": messages,
79 "cache_control": { "type": "ephemeral" },
81 });
82 let mut headers = vec![("anthropic-version", API_VERSION.to_string())];
83 if FALLBACK_MODELS.contains(&request.model) {
84 body["fallbacks"] = json!("default");
85 headers.push(("anthropic-beta", FALLBACK_BETA.to_string()));
86 }
87 HttpCall {
88 path: "/messages",
89 headers,
90 body,
91 }
92 }
93
94 fn decode(&self, body: &Value) -> Result<Reply, LlmError> {
95 let malformed = |what: &str| LlmError::Malformed(what.to_string());
96 let content = body["content"]
97 .as_array()
98 .ok_or_else(|| malformed("response without content"))?
99 .clone();
100 let stop_reason = body["stop_reason"]
101 .as_str()
102 .ok_or_else(|| malformed("response without stop_reason"))?;
103 let stop = match stop_reason {
104 "end_turn" | "stop_sequence" => Stop::Done,
105 "max_tokens" => Stop::Truncated,
106 "refusal" => Stop::Refused,
107 "pause_turn" => Stop::Paused,
108 "tool_use" => Stop::ToolCalls,
109 other => return Err(malformed(&format!("unexpected stop_reason `{other}`"))),
110 };
111 let mut texts = Vec::new();
112 let mut calls = Vec::new();
113 for block in &content {
114 match block["type"].as_str() {
115 Some("text") => {
116 if let Some(t) = block["text"]
117 .as_str()
118 .map(str::trim)
119 .filter(|t| !t.is_empty())
120 {
121 texts.push(t.to_string());
122 }
123 }
124 Some("tool_use") => {
125 let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str())
126 else {
127 return Err(malformed("tool_use without id or name"));
128 };
129 calls.push(ToolCall {
130 id: id.to_string(),
131 name: name.to_string(),
132 input: Ok(block.get("input").cloned().unwrap_or(json!({}))),
133 });
134 }
135 _ => {}
136 }
137 }
138 let usage = &body["usage"];
139 let tokens = |key: &str| usage[key].as_u64().unwrap_or(0);
140 let cached = tokens("cache_read_input_tokens");
141 Ok(Reply {
142 text: texts.join("\n\n"),
143 calls,
144 raw: Value::Array(content),
145 stop,
146 usage: Usage {
147 input: tokens("input_tokens") + cached + tokens("cache_creation_input_tokens"),
148 cached_input: cached,
149 output: tokens("output_tokens"),
150 reported: usage.is_object(),
151 },
152 })
153 }
154
155 fn error(&self, status: u16, headers: &HeaderMap, body: &str) -> LlmError {
156 if status == 429 && retry_after(headers).is_none() {
158 return LlmError::Quota;
159 }
160 status_error(status, headers, body)
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use std::time::Duration;
167
168 use super::*;
169 use crate::llm::{complete, testing::recorded, HttpResponse, ToolResult, ToolSpec, Transport};
170
171 macro_rules! fixture {
172 ($name:literal) => {
173 recorded(include_str!(concat!(
174 "../../tests/fixtures/llm/anthropic/",
175 $name,
176 ".json"
177 )))
178 };
179 }
180
181 struct Fixed(HttpResponse);
182 impl Transport for Fixed {
183 async fn send(&self, _: &HttpCall) -> Result<HttpResponse, LlmError> {
184 Ok(self.0.clone())
185 }
186 }
187
188 async fn reply(resp: HttpResponse) -> Result<Reply, LlmError> {
189 let req = Request {
190 model: "claude-opus-5-5",
191 system: "s",
192 tools: &[],
193 turns: &[Turn::User("q".into())],
194 max_tokens: 10,
195 };
196 complete(&Anthropic, &Fixed(resp), &req).await
197 }
198
199 fn spec() -> ToolSpec {
200 ToolSpec {
201 name: "get_item".into(),
202 description: "d".into(),
203 schema: json!({ "type": "object" }),
204 }
205 }
206
207 #[test]
208 fn requests_have_the_messages_api_shape() {
209 let tools = [spec()];
210 let turns = [Turn::User("hi".into())];
211 let request = |model| Request {
212 model,
213 system: "sys",
214 tools: &tools,
215 turns: &turns,
216 max_tokens: 16_000,
217 };
218 let call = Anthropic.encode(&request("claude-opus-5-5"));
219 assert_eq!(call.path, "/messages");
220 assert_eq!(call.body["model"], "claude-opus-5-5");
221 assert_eq!(call.body["max_tokens"], 16_000);
222 assert_eq!(call.body["system"], "sys");
223 assert_eq!(call.body["messages"][0]["content"], "hi");
224 assert_eq!(call.body["cache_control"]["type"], "ephemeral");
225 assert_eq!(call.body["fallbacks"], "default");
226 assert!(call
227 .headers
228 .contains(&("anthropic-beta", FALLBACK_BETA.to_string())));
229 assert!(call
230 .headers
231 .contains(&("anthropic-version", API_VERSION.to_string())));
232 assert!(
233 call.body.get("thinking").is_none() && call.body.get("temperature").is_none(),
234 "model defaults: adaptive thinking, no sampling parameters"
235 );
236 let first = &call.body["tools"][0];
237 assert_eq!(first["name"], "get_item");
238 assert_eq!(first["input_schema"]["type"], "object");
239 assert!(first.get("schema").is_none());
240
241 let call = Anthropic.encode(&request("claude-haiku-4-5"));
243 assert!(call.body.get("fallbacks").is_none());
244 assert!(!call.headers.iter().any(|(k, _)| *k == "anthropic-beta"));
245 }
246
247 #[test]
248 fn turns_are_encoded_unchanged_and_results_in_one_message() {
249 let raw = json!([{ "type": "thinking", "thinking": "", "signature": "sig" },
250 { "type": "tool_use", "id": "t1", "name": "get_item", "input": {} }]);
251 let turns = [
252 Turn::User("q".into()),
253 Turn::Assistant {
254 text: String::new(),
255 calls: vec![],
256 raw: raw.clone(),
257 },
258 Turn::ToolResults(vec![
259 ToolResult {
260 call_id: "t1".into(),
261 name: "get_item".into(),
262 text: "ok".into(),
263 is_error: false,
264 },
265 ToolResult {
266 call_id: "t2".into(),
267 name: "get_item".into(),
268 text: "nope".into(),
269 is_error: true,
270 },
271 ]),
272 ];
273 let call = Anthropic.encode(&Request {
274 model: "m",
275 system: "s",
276 tools: &[],
277 turns: &turns,
278 max_tokens: 1,
279 });
280 let messages = call.body["messages"].as_array().unwrap();
281 assert_eq!(messages.len(), 3);
282 assert_eq!(messages[1]["content"], raw);
283 let results = messages[2]["content"].as_array().unwrap();
284 assert_eq!(results.len(), 2, "all results in one message");
285 assert_eq!(results[0]["tool_use_id"], "t1");
286 assert_eq!(results[0]["is_error"], false);
287 assert_eq!(results[1]["is_error"], true);
288 }
289
290 #[tokio::test]
291 async fn recorded_replies_are_decoded() {
292 let r = reply(fixture!("answer")).await.unwrap();
293 assert_eq!(r.stop, Stop::Done);
294 assert_eq!(r.text, "## Ready\n- PRT-000001 is released");
295 assert!(r.calls.is_empty());
296 assert_eq!(r.usage.input, 200);
297 assert_eq!(r.usage.output, 80);
298
299 let r = reply(fixture!("tool_use")).await.unwrap();
300 assert_eq!(r.stop, Stop::ToolCalls);
301 assert_eq!(r.text, "Let me look that up.");
302 assert_eq!(r.raw.as_array().unwrap().len(), 3, "thinking block kept");
303 assert_eq!(
304 r.calls,
305 vec![ToolCall {
306 id: "toolu_01".into(),
307 name: "get_item".into(),
308 input: Ok(json!({ "item": "PRT-000001" })),
309 }]
310 );
311 assert_eq!(
312 (r.usage.input, r.usage.cached_input, r.usage.output),
313 (160, 50, 20),
314 "input counts the cache tokens"
315 );
316
317 let r = reply(fixture!("parallel_tool_use")).await.unwrap();
318 assert_eq!(r.calls.len(), 2);
319 assert_eq!(r.calls[1].id, "toolu_02");
320
321 assert_eq!(reply(fixture!("pause")).await.unwrap().stop, Stop::Paused);
322 assert_eq!(
323 reply(fixture!("refusal")).await.unwrap().stop,
324 Stop::Refused
325 );
326 let r = reply(fixture!("max_tokens")).await.unwrap();
327 assert_eq!((r.stop, r.text.as_str()), (Stop::Truncated, "partial"));
328 }
329
330 #[test]
331 fn unusual_bodies() {
332 let d = |v: Value| Anthropic.decode(&v);
333 let r = d(json!({
334 "content": [{ "type": "text", "text": "A " }, { "type": "fallback" }, { "type": "text", "text": "B" }],
335 "stop_reason": "end_turn"
336 }))
337 .unwrap();
338 assert_eq!(r.text, "A\n\nB");
339 assert!(matches!(
340 d(json!({ "content": [] })),
341 Err(LlmError::Malformed(_))
342 ));
343 assert!(matches!(
344 d(json!({ "content": [], "stop_reason": "weird" })),
345 Err(LlmError::Malformed(_))
346 ));
347 assert!(d(json!({
348 "content": [{ "type": "tool_use", "name": "x" }], "stop_reason": "tool_use"
349 }))
350 .is_err());
351 }
352
353 #[tokio::test]
354 async fn recorded_errors_are_mapped() {
355 assert_eq!(
356 reply(fixture!("error_429")).await.unwrap_err(),
357 LlmError::RateLimited {
358 retry_after: Some(Duration::from_secs(7))
359 }
360 );
361 assert_eq!(
362 reply(fixture!("error_529")).await.unwrap_err(),
363 LlmError::Overloaded(529)
364 );
365 assert_eq!(
366 reply(fixture!("error_401")).await.unwrap_err(),
367 LlmError::Auth
368 );
369 assert_eq!(
370 reply(fixture!("error_400")).await.unwrap_err(),
371 LlmError::Rejected {
372 status: 400,
373 message: "bad model".into()
374 }
375 );
376 assert_eq!(
378 Anthropic.error(429, &HeaderMap::new(), "{}"),
379 LlmError::Quota
380 );
381 assert_eq!(
382 Anthropic.error(400, &HeaderMap::new(), "prompt is too long: 250000 tokens"),
383 LlmError::ContextLength
384 );
385 let e = Anthropic.error(302, &HeaderMap::new(), "");
387 assert!(matches!(e, LlmError::Rejected { status: 302, .. }));
388 }
389}