Skip to main content

teamcraft/llm/
anthropic.rs

1//! The Anthropic Messages API: request encoding (prompt caching, the
2//! server-side fallback beta), reply decoding and the error table.
3
4use 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
12/// Default API base.
13pub const BASE_URL: &str = "https://api.anthropic.com/v1";
14/// `anthropic-version` header.
15pub const API_VERSION: &str = "2026-10-21";
16/// Beta header of `fallbacks: "default"` (server-side refusal fallback).
17pub const FALLBACK_BETA: &str = "server-side-fallback-2026-07-01";
18/// Models that accept `fallbacks: "default"` on the Claude API.
19const FALLBACK_MODELS: &[&str] = &[
20    "claude-opus-5-5",
21    "claude-opus-5",
22    "claude-fable-5-1",
23    "claude-sonnet-5-5",
24];
25
26/// The Messages API wire format.
27pub 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            // All results go back in one user message.
35            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            // Automatic prompt caching: every step re-sends the same prefix.
80            "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        // A 429 without `retry-after` is the spend cap: waiting does not help.
157        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        // other models: no fallback parameter, no beta header
242        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        // a 429 without retry-after is the spend cap: no retry
377        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        // a 3xx (redirects are not followed) is an error, not a body
386        let e = Anthropic.error(302, &HeaderMap::new(), "");
387        assert!(matches!(e, LlmError::Rejected { status: 302, .. }));
388    }
389}