Skip to main content

jamjet_models/
anthropic.rs

1//! Anthropic Claude adapter (Messages API).
2//!
3//! Supports claude-3-haiku, claude-3-sonnet, claude-3-opus, claude-sonnet-4-6, etc.
4//! Reads `ANTHROPIC_API_KEY` from the environment.
5
6use crate::adapter::{
7    warn_tools_not_forwarded, ChatMessage, ChatRole, ModelAdapter, ModelConfig, ModelError,
8    ModelRequest, ModelResponse, StructuredRequest,
9};
10use async_trait::async_trait;
11use serde_json::{json, Value};
12use tracing::{debug, instrument};
13
14const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com";
15const ANTHROPIC_VERSION: &str = "2023-06-01";
16const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
17const DEFAULT_MAX_TOKENS: u32 = 4096;
18
19/// Anthropic Claude adapter.
20pub struct AnthropicAdapter {
21    client: reqwest::Client,
22    api_key: String,
23    default_model: String,
24}
25
26impl AnthropicAdapter {
27    /// Create an adapter with the given API key.
28    pub fn new(api_key: impl Into<String>) -> Self {
29        Self {
30            client: reqwest::Client::new(),
31            api_key: api_key.into(),
32            default_model: DEFAULT_MODEL.into(),
33        }
34    }
35
36    /// Create adapter from `ANTHROPIC_API_KEY` env var.
37    pub fn from_env() -> Result<Self, ModelError> {
38        let key = std::env::var("ANTHROPIC_API_KEY")
39            .map_err(|_| ModelError::Network("ANTHROPIC_API_KEY not set".into()))?;
40        Ok(Self::new(key))
41    }
42
43    pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
44        self.default_model = model.into();
45        self
46    }
47
48    async fn call_api(&self, body: Value) -> Result<Value, ModelError> {
49        let resp = self
50            .client
51            .post(format!("{ANTHROPIC_API_BASE}/v1/messages"))
52            .header("x-api-key", &self.api_key)
53            .header("anthropic-version", ANTHROPIC_VERSION)
54            .header("content-type", "application/json")
55            .json(&body)
56            .send()
57            .await
58            .map_err(|e| ModelError::Network(e.to_string()))?;
59
60        let status = resp.status().as_u16();
61        let body_text = resp
62            .text()
63            .await
64            .map_err(|e| ModelError::Network(e.to_string()))?;
65
66        if status == 429 {
67            return Err(ModelError::RateLimited {
68                retry_after_secs: 60,
69            });
70        }
71        if status != 200 {
72            return Err(ModelError::Api {
73                status,
74                body: body_text,
75            });
76        }
77
78        serde_json::from_str(&body_text).map_err(|e| ModelError::Serialization(e.to_string()))
79    }
80
81    fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
82        let raw_model = config.model.as_deref().unwrap_or(&self.default_model);
83        // Strip "anthropic/" prefix when present — callers may send the fully-qualified
84        // form ("anthropic/claude-sonnet-4-6") but the Anthropic API expects bare names.
85        let model = raw_model.strip_prefix("anthropic/").unwrap_or(raw_model);
86        let max_tokens = config.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
87
88        // Anthropic separates system prompt from messages.
89        let system_prompt = config.system_prompt.as_deref().or_else(|| {
90            // Extract system message from messages list if present.
91            messages
92                .iter()
93                .find(|m| matches!(m.role, ChatRole::System))
94                .map(|m| m.content.as_str())
95        });
96
97        let anthropic_messages: Vec<Value> = messages
98            .iter()
99            .filter(|m| !matches!(m.role, ChatRole::System))
100            .map(|m| {
101                let role = match m.role {
102                    ChatRole::User | ChatRole::Tool => "user",
103                    ChatRole::Assistant => "assistant",
104                    ChatRole::System => "user", // already filtered
105                };
106                json!({ "role": role, "content": m.content })
107            })
108            .collect();
109
110        let mut body = json!({
111            "model": model,
112            "max_tokens": max_tokens,
113            "messages": anthropic_messages,
114        });
115
116        if let Some(system) = system_prompt {
117            body["system"] = json!(system);
118        }
119        if let Some(temp) = config.temperature {
120            body["temperature"] = json!(temp);
121        }
122        if let Some(stops) = &config.stop_sequences {
123            body["stop_sequences"] = json!(stops);
124        }
125
126        body
127    }
128
129    fn parse_response(&self, resp: Value) -> Result<ModelResponse, ModelError> {
130        let model = resp["model"]
131            .as_str()
132            .unwrap_or(&self.default_model)
133            .to_string();
134
135        let content = resp["content"]
136            .as_array()
137            .and_then(|blocks| {
138                blocks
139                    .iter()
140                    .find(|b| b["type"].as_str() == Some("text"))
141                    .and_then(|b| b["text"].as_str())
142            })
143            .unwrap_or("")
144            .to_string();
145
146        let finish_reason = resp["stop_reason"].as_str().unwrap_or("stop").to_string();
147        let input_tokens = resp["usage"]["input_tokens"].as_u64().unwrap_or(0);
148        let output_tokens = resp["usage"]["output_tokens"].as_u64().unwrap_or(0);
149
150        Ok(ModelResponse {
151            content,
152            model,
153            finish_reason,
154            input_tokens,
155            output_tokens,
156            structured: None,
157            tool_calls: vec![],
158        })
159    }
160}
161
162#[async_trait]
163impl ModelAdapter for AnthropicAdapter {
164    fn system_name(&self) -> &'static str {
165        "anthropic"
166    }
167
168    fn default_model(&self) -> &str {
169        &self.default_model
170    }
171
172    #[instrument(skip(self, request), fields(
173        gen_ai.system = "anthropic",
174        gen_ai.request.model = tracing::field::Empty,
175        gen_ai.usage.input_tokens = tracing::field::Empty,
176        gen_ai.usage.output_tokens = tracing::field::Empty,
177    ))]
178    async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
179        // Native adapter: tools are not forwarded to the provider. Warn (once) so
180        // a tool-carrying call does not silently degenerate the agent loop.
181        if !request.tools.is_empty() {
182            warn_tools_not_forwarded(self.system_name());
183        }
184
185        let model = request
186            .config
187            .model
188            .as_deref()
189            .unwrap_or(&self.default_model)
190            .to_string();
191        tracing::Span::current().record("gen_ai.request.model", model.as_str());
192
193        debug!(model = %model, "Calling Anthropic Messages API");
194
195        let body = self.build_request_body(&request.messages, &request.config);
196        let resp_json = self.call_api(body).await?;
197        let response = self.parse_response(resp_json)?;
198
199        tracing::Span::current()
200            .record("gen_ai.usage.input_tokens", response.input_tokens)
201            .record("gen_ai.usage.output_tokens", response.output_tokens);
202
203        Ok(response)
204    }
205
206    #[instrument(skip(self, request), fields(
207        gen_ai.system = "anthropic",
208        gen_ai.request.model = tracing::field::Empty,
209    ))]
210    async fn structured_output(
211        &self,
212        request: StructuredRequest,
213    ) -> Result<ModelResponse, ModelError> {
214        let model = request
215            .config
216            .model
217            .as_deref()
218            .unwrap_or(&self.default_model)
219            .to_string();
220        tracing::Span::current().record("gen_ai.request.model", model.as_str());
221
222        // Append schema instruction to system prompt.
223        let schema_str = serde_json::to_string_pretty(&request.output_schema)
224            .map_err(|e| ModelError::Serialization(e.to_string()))?;
225        let mut config = request.config.clone();
226        let system = config.system_prompt.get_or_insert_with(String::new);
227        system.push_str(&format!(
228            "\n\nRespond ONLY with a valid JSON object matching this schema:\n{schema_str}\nDo not include any other text."
229        ));
230
231        let chat_req = ModelRequest {
232            messages: request.messages,
233            config,
234            tools: vec![],
235        };
236        let mut response = self.chat(chat_req).await?;
237
238        // Parse structured output from the response content.
239        let structured = serde_json::from_str::<Value>(&response.content)
240            .or_else(|_| {
241                // Try to extract JSON from markdown code blocks.
242                let trimmed = response.content.trim();
243                let inner = trimmed
244                    .trim_start_matches("```json")
245                    .trim_start_matches("```")
246                    .trim_end_matches("```")
247                    .trim();
248                serde_json::from_str::<Value>(inner)
249            })
250            .map_err(|e| {
251                ModelError::Serialization(format!("failed to parse structured output: {e}"))
252            })?;
253
254        response.structured = Some(structured);
255        Ok(response)
256    }
257}