Skip to main content

agentd/intel/
anthropic.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Anthropic Messages API adapter. Pure translation, no I/O — every dial goes
3//! through `intel::client`, so this module stays testable without a network.
4//!
5//! Three differences from the OpenAI dialect are absorbed here and never
6//! surface to the loop: `system` is a top-level field rather than a message,
7//! tool calls and results are content *blocks* rather than message fields, and
8//! tool arguments arrive as a JSON object rather than a stringified one.
9
10use crate::wire::intel::{Message, Request, Response, StopReason, ToolCall, Usage};
11use serde_json::{Map, Value, json};
12
13pub const DEFAULT_PATH: &str = "/v1/messages";
14const ANTHROPIC_VERSION: &str = "2023-06-01";
15
16pub fn build_request(req: &Request, token: Option<&str>) -> (Vec<u8>, Vec<(String, String)>) {
17    // System messages are hoisted into the top-level `system` field.
18    let system: String = req
19        .messages
20        .iter()
21        .filter_map(|m| match m {
22            Message::System(s) => Some(s.as_str()),
23            _ => None,
24        })
25        .collect::<Vec<_>>()
26        .join("\n\n");
27
28    let messages: Vec<Value> = req
29        .messages
30        .iter()
31        .filter_map(message_to_anthropic)
32        .collect();
33
34    let mut body = Map::new();
35    body.insert("model".into(), json!(req.model));
36    body.insert("max_tokens".into(), json!(req.max_tokens));
37    if !system.is_empty() {
38        body.insert("system".into(), json!(system));
39    }
40    if let Some(t) = req.temperature {
41        body.insert("temperature".into(), json!(t));
42    }
43    body.insert("messages".into(), json!(messages));
44    if !req.tools.is_empty() {
45        let tools: Vec<Value> = req
46            .tools
47            .iter()
48            .map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.input_schema}))
49            .collect();
50        body.insert("tools".into(), json!(tools));
51    }
52
53    let bytes = serde_json::to_vec(&Value::Object(body)).unwrap_or_default();
54    let mut headers = vec![
55        ("content-type".to_string(), "application/json".to_string()),
56        (
57            "anthropic-version".to_string(),
58            ANTHROPIC_VERSION.to_string(),
59        ),
60    ];
61    if let Some(tok) = token {
62        headers.push(("x-api-key".to_string(), tok.to_string()));
63    }
64    (bytes, headers)
65}
66
67fn message_to_anthropic(m: &Message) -> Option<Value> {
68    match m {
69        Message::System(_) => None, // hoisted into `system`
70        Message::User(s) => Some(json!({"role": "user", "content": s})),
71        Message::Assistant { text, tool_calls } => {
72            let mut blocks: Vec<Value> = Vec::new();
73            if let Some(t) = text.as_deref().filter(|t| !t.is_empty()) {
74                blocks.push(json!({"type": "text", "text": t}));
75            }
76            for tc in tool_calls {
77                blocks.push(json!({"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments}));
78            }
79            Some(json!({"role": "assistant", "content": blocks}))
80        }
81        Message::ToolResult {
82            id,
83            content,
84            is_error,
85        } => Some(json!({
86            "role": "user",
87            "content": [{
88                "type": "tool_result",
89                "tool_use_id": id,
90                "content": content,
91                "is_error": is_error,
92            }]
93        })),
94    }
95}
96
97pub fn parse_response(body: &[u8]) -> Result<Response, String> {
98    let v: Value =
99        serde_json::from_slice(body).map_err(|e| format!("intel: bad JSON response: {e}"))?;
100
101    if v.get("type").and_then(Value::as_str) == Some("error") {
102        let msg = v
103            .get("error")
104            .and_then(|e| e.get("message"))
105            .and_then(Value::as_str)
106            .unwrap_or("unknown");
107        return Err(format!("intel: provider error: {msg}"));
108    }
109
110    let mut text_parts: Vec<String> = Vec::new();
111    let mut tool_calls = Vec::new();
112    if let Some(blocks) = v.get("content").and_then(Value::as_array) {
113        for b in blocks {
114            match b.get("type").and_then(Value::as_str) {
115                Some("text") => {
116                    if let Some(t) = b.get("text").and_then(Value::as_str) {
117                        text_parts.push(t.to_string());
118                    }
119                }
120                Some("tool_use") => {
121                    tool_calls.push(ToolCall {
122                        id: b
123                            .get("id")
124                            .and_then(Value::as_str)
125                            .unwrap_or("")
126                            .to_string(),
127                        name: b
128                            .get("name")
129                            .and_then(Value::as_str)
130                            .unwrap_or("")
131                            .to_string(),
132                        arguments: b.get("input").cloned().unwrap_or(Value::Null),
133                    });
134                }
135                _ => {}
136            }
137        }
138    }
139
140    let stop_reason = match v.get("stop_reason").and_then(Value::as_str) {
141        Some("end_turn") | Some("stop_sequence") => StopReason::EndTurn,
142        Some("tool_use") => StopReason::ToolUse,
143        Some("max_tokens") => StopReason::MaxTokens,
144        _ => StopReason::Other,
145    };
146
147    let usage = v.get("usage").map(|u| Usage {
148        input_tokens: u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0),
149        output_tokens: u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0),
150    });
151
152    let text = if text_parts.is_empty() {
153        None
154    } else {
155        Some(text_parts.join(""))
156    };
157    Ok(Response {
158        text,
159        tool_calls,
160        stop_reason,
161        usage: usage.unwrap_or_default(),
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::wire::intel::ToolDef;
169
170    #[test]
171    fn build_hoists_system_and_headers() {
172        let req = Request {
173            model: "claude-x".into(),
174            messages: vec![Message::system("be terse"), Message::user("hi")],
175            tools: vec![ToolDef {
176                name: "t".into(),
177                description: "d".into(),
178                input_schema: json!({}),
179            }],
180            max_tokens: 100,
181            temperature: None,
182        };
183        let (body, headers) = build_request(&req, Some("sk-ant"));
184        let v: Value = serde_json::from_slice(&body).unwrap();
185        assert_eq!(v["system"], "be terse");
186        assert_eq!(v["messages"][0]["role"], "user");
187        assert_eq!(v["tools"][0]["name"], "t");
188        assert!(
189            headers
190                .iter()
191                .any(|(k, val)| k == "x-api-key" && val == "sk-ant")
192        );
193        assert!(headers.iter().any(|(k, _)| k == "anthropic-version"));
194    }
195
196    #[test]
197    fn parse_text_and_tool_use() {
198        let body = br#"{"content":[{"type":"text","text":"hi"},{"type":"tool_use","id":"tu_1","name":"read","input":{"p":1}}],"stop_reason":"tool_use","usage":{"input_tokens":5,"output_tokens":7}}"#;
199        let r = parse_response(body).unwrap();
200        assert_eq!(r.text.as_deref(), Some("hi"));
201        assert_eq!(r.tool_calls[0].name, "read");
202        assert_eq!(r.tool_calls[0].arguments["p"], 1);
203        assert_eq!(r.stop_reason, StopReason::ToolUse);
204        assert_eq!(r.usage.total(), 12);
205    }
206}