fleetforge_runtime/gateway/
anthropic.rs1use 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}