Skip to main content

agentd/intel/
anthropic.rs

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