fleetforge_runtime/gateway/
anthropic.rs

1use std::sync::Arc;
2
3use anyhow::{anyhow, Context, Result};
4use async_trait::async_trait;
5use reqwest::{header, Client, Url};
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8use tracing::debug;
9
10use crate::gateway::LanguageModel;
11use fleetforge_prompt::{ChatMessage, ChatRole, ModelResponse, ModelUsage, ToolSpec};
12use fleetforge_telemetry::context::TraceContext;
13use fleetforge_trust::Trust;
14
15const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
16const DEFAULT_VERSION: &str = "2023-06-01";
17const STRUCTURED_TOOL_NAME: &str = "structured_output";
18
19#[derive(Debug, Clone)]
20pub struct AnthropicConfig {
21    pub api_key: String,
22    pub default_model: String,
23    pub base_url: String,
24    pub api_version: String,
25    pub max_output_tokens: usize,
26    pub client: Option<Client>,
27}
28
29impl AnthropicConfig {
30    pub fn new(api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
31        Self {
32            api_key: api_key.into(),
33            default_model: default_model.into(),
34            base_url: DEFAULT_BASE_URL.to_string(),
35            api_version: DEFAULT_VERSION.to_string(),
36            max_output_tokens: 1024,
37            client: None,
38        }
39    }
40
41    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
42        self.base_url = base_url.into();
43        self
44    }
45
46    pub fn api_version(mut self, version: impl Into<String>) -> Self {
47        self.api_version = version.into();
48        self
49    }
50
51    pub fn max_output_tokens(mut self, tokens: usize) -> Self {
52        self.max_output_tokens = tokens;
53        self
54    }
55
56    pub fn client(mut self, client: Client) -> Self {
57        self.client = Some(client);
58        self
59    }
60}
61
62#[derive(Clone)]
63pub struct AnthropicLanguageModel {
64    client: Client,
65    config: Arc<AnthropicConfig>,
66    messages_url: Url,
67}
68
69impl AnthropicLanguageModel {
70    pub fn new(config: AnthropicConfig) -> Result<Self> {
71        if config.api_key.trim().is_empty() {
72            return Err(anyhow!("Anthropic api_key must not be empty"));
73        }
74        let client = config
75            .client
76            .clone()
77            .unwrap_or_else(|| Client::builder().build().expect("reqwest client"));
78        let base = Url::parse(&config.base_url).context("invalid Anthropic base_url")?;
79        let messages_url = base
80            .join("/v1/messages")
81            .context("invalid Anthropic messages endpoint")?;
82        Ok(Self {
83            client,
84            config: Arc::new(config),
85            messages_url,
86        })
87    }
88}
89
90#[async_trait]
91impl LanguageModel for AnthropicLanguageModel {
92    async fn chat(
93        &self,
94        messages: &[ChatMessage],
95        tools: Option<&[ToolSpec]>,
96        response_schema: Option<&Value>,
97        strict: bool,
98        params: &Value,
99        trace: &TraceContext,
100    ) -> Result<ModelResponse> {
101        let model = params
102            .get("model")
103            .and_then(Value::as_str)
104            .map(|s| s.to_string())
105            .unwrap_or_else(|| self.config.default_model.clone());
106
107        let max_tokens = params
108            .get("max_output_tokens")
109            .and_then(Value::as_u64)
110            .map(|v| v as usize)
111            .unwrap_or(self.config.max_output_tokens);
112
113        let mut provider_messages = map_messages(messages)?;
114        let mut tool_list = tools.map(map_tools).unwrap_or_else(|| Ok(Vec::new()))?;
115        let mut tool_choice = params.get("tool_choice").cloned();
116
117        if let Some(schema) = response_schema {
118            let structured_tool = json!({
119                "name": STRUCTURED_TOOL_NAME,
120                "description": "Return the structured response as JSON",
121                "input_schema": schema,
122            });
123            tool_list.push(structured_tool);
124            if strict {
125                tool_choice = Some(json!({ "type": "tool", "name": STRUCTURED_TOOL_NAME }));
126            }
127        }
128
129        let mut body = json!({
130            "model": model,
131            "messages": provider_messages,
132            "max_tokens": max_tokens,
133        });
134
135        if let Some(temp) = params.get("temperature").and_then(Value::as_f64) {
136            body["temperature"] = json!(temp);
137        }
138        if !tool_list.is_empty() {
139            body["tools"] = Value::Array(tool_list);
140        }
141        if let Some(choice) = tool_choice {
142            body["tool_choice"] = choice;
143        }
144
145        let mut request = self
146            .client
147            .post(self.messages_url.clone())
148            .header("x-api-key", &self.config.api_key)
149            .header("anthropic-version", &self.config.api_version)
150            .header(header::CONTENT_TYPE, "application/json")
151            .json(&body);
152        for (key, value) in trace.w3c_headers() {
153            request = request.header(&key, value);
154        }
155        let response = request.send().await.context("anthropic request failed")?;
156
157        let status = response.status();
158        let body_value: Value = response
159            .json()
160            .await
161            .context("failed to decode anthropic response body")?;
162
163        if !status.is_success() {
164            return Err(anyhow!(
165                "anthropic error {}: {}",
166                status,
167                body_value
168                    .get("error")
169                    .and_then(|err| err.get("message"))
170                    .and_then(Value::as_str)
171                    .unwrap_or_else(|| body_value.to_string().as_str())
172            ));
173        }
174
175        let content = body_value
176            .get("content")
177            .and_then(Value::as_array)
178            .cloned()
179            .unwrap_or_default();
180
181        let (messages_out, response_json) = map_response_content(content, response_schema, strict)?;
182
183        let usage_metrics = body_value.get("usage").map(|usage| ModelUsage {
184            prompt_tokens: usage.get("input_tokens").and_then(Value::as_i64),
185            completion_tokens: usage.get("output_tokens").and_then(Value::as_i64),
186            total_tokens: None,
187            cost: None,
188        });
189
190        debug!("anthropic response usage" = ?usage_metrics);
191
192        Ok(ModelResponse {
193            messages: messages_out,
194            response_json,
195            usage: usage_metrics,
196            provider: Some("anthropic".to_string()),
197            provider_version: Some(self.config.api_version.clone()),
198            raw: Some(body_value),
199        })
200    }
201}
202
203fn map_messages(messages: &[ChatMessage]) -> Result<Vec<Value>> {
204    messages
205        .iter()
206        .map(|message| {
207            let role = match message.role {
208                ChatRole::System => "system",
209                ChatRole::User => "user",
210                ChatRole::Assistant => "assistant",
211                ChatRole::Tool => "assistant",
212            };
213            let content_value = value_to_anthropic_content(&message.content)?;
214            Ok(json!({
215                "role": role,
216                "content": content_value,
217            }))
218        })
219        .collect()
220}
221
222fn value_to_anthropic_content(value: &Value) -> Result<Value> {
223    if let Some(text) = value.as_str() {
224        return Ok(json!([{ "type": "text", "text": text }]));
225    }
226    if value.is_null() {
227        return Ok(json!([{ "type": "text", "text": "" }]));
228    }
229    if let Some(array) = value.as_array() {
230        let mut parts = Vec::with_capacity(array.len());
231        for item in array {
232            if let Some(text) = item.as_str() {
233                parts.push(json!({"type":"text","text":text}));
234            } else {
235                parts.push(json!({"type":"json","json":item}));
236            }
237        }
238        return Ok(Value::Array(parts));
239    }
240    Ok(json!([{ "type": "json", "json": value.clone() }]))
241}
242
243fn map_tools(tools: &[ToolSpec]) -> Result<Vec<Value>> {
244    tools
245        .iter()
246        .map(|tool| {
247            if tool.name.is_empty() {
248                return Err(anyhow!("tool name must not be empty"));
249            }
250            Ok(json!({
251                "name": tool.name,
252                "description": tool.description,
253                "input_schema": tool.schema.clone(),
254            }))
255        })
256        .collect()
257}
258
259fn map_response_content(
260    content: Vec<Value>,
261    response_schema: Option<&Value>,
262    strict: bool,
263) -> Result<(Vec<ChatMessage>, Option<Value>)> {
264    let mut messages = Vec::new();
265    let mut structured: Option<Value> = None;
266
267    for item in content {
268        match item.get("type").and_then(Value::as_str) {
269            Some("text") => {
270                let text = item
271                    .get("text")
272                    .cloned()
273                    .unwrap_or(Value::String(String::new()));
274                messages.push(ChatMessage {
275                    role: ChatRole::Assistant,
276                    content: text,
277                    name: None,
278                    tool_call_id: None,
279                    metadata: None,
280                    trust: Some(Trust::Untrusted),
281                    trust_origin: None,
282                });
283            }
284            Some("tool_use") => {
285                let name = item
286                    .get("name")
287                    .and_then(Value::as_str)
288                    .map(|s| s.to_string());
289                let input = item.get("input").cloned().unwrap_or(Value::Null);
290                messages.push(ChatMessage {
291                    role: ChatRole::Assistant,
292                    content: json!({"tool_use": item}),
293                    name,
294                    tool_call_id: item
295                        .get("id")
296                        .and_then(Value::as_str)
297                        .map(|s| s.to_string()),
298                    metadata: None,
299                    trust: Some(Trust::Untrusted),
300                    trust_origin: None,
301                });
302                if structured.is_none() {
303                    structured = Some(input);
304                }
305            }
306            _ => {
307                messages.push(ChatMessage {
308                    role: ChatRole::Assistant,
309                    content: item.clone(),
310                    name: None,
311                    tool_call_id: None,
312                    metadata: None,
313                    trust: Some(Trust::Untrusted),
314                    trust_origin: None,
315                });
316            }
317        }
318    }
319
320    if strict && response_schema.is_some() && structured.is_none() {
321        return Err(anyhow!("anthropic response missing structured tool output"));
322    }
323
324    Ok((messages, structured))
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn map_tools_produces_expected_shape() {
333        let tools = vec![ToolSpec {
334            name: "weather".into(),
335            description: "Weather lookup".into(),
336            schema: json!({"type":"object"}),
337            trust: None,
338            trust_origin: None,
339        }];
340        let mapped = map_tools(&tools).unwrap();
341        assert_eq!(mapped.len(), 1);
342        assert_eq!(mapped[0]["name"], json!("weather"));
343    }
344}