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    ChatMessage, ChatRole, ModelAdapter, ModelConfig, ModelError, ModelRequest, ModelResponse,
8    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 model = config.model.as_deref().unwrap_or(&self.default_model);
83        let max_tokens = config.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
84
85        // Anthropic separates system prompt from messages.
86        let system_prompt = config.system_prompt.as_deref().or_else(|| {
87            // Extract system message from messages list if present.
88            messages
89                .iter()
90                .find(|m| matches!(m.role, ChatRole::System))
91                .map(|m| m.content.as_str())
92        });
93
94        let anthropic_messages: Vec<Value> = messages
95            .iter()
96            .filter(|m| !matches!(m.role, ChatRole::System))
97            .map(|m| {
98                let role = match m.role {
99                    ChatRole::User | ChatRole::Tool => "user",
100                    ChatRole::Assistant => "assistant",
101                    ChatRole::System => "user", // already filtered
102                };
103                json!({ "role": role, "content": m.content })
104            })
105            .collect();
106
107        let mut body = json!({
108            "model": model,
109            "max_tokens": max_tokens,
110            "messages": anthropic_messages,
111        });
112
113        if let Some(system) = system_prompt {
114            body["system"] = json!(system);
115        }
116        if let Some(temp) = config.temperature {
117            body["temperature"] = json!(temp);
118        }
119        if let Some(stops) = &config.stop_sequences {
120            body["stop_sequences"] = json!(stops);
121        }
122
123        body
124    }
125
126    fn parse_response(&self, resp: Value) -> Result<ModelResponse, ModelError> {
127        let model = resp["model"]
128            .as_str()
129            .unwrap_or(&self.default_model)
130            .to_string();
131
132        let content = resp["content"]
133            .as_array()
134            .and_then(|blocks| {
135                blocks
136                    .iter()
137                    .find(|b| b["type"].as_str() == Some("text"))
138                    .and_then(|b| b["text"].as_str())
139            })
140            .unwrap_or("")
141            .to_string();
142
143        let finish_reason = resp["stop_reason"].as_str().unwrap_or("stop").to_string();
144        let input_tokens = resp["usage"]["input_tokens"].as_u64().unwrap_or(0);
145        let output_tokens = resp["usage"]["output_tokens"].as_u64().unwrap_or(0);
146
147        Ok(ModelResponse {
148            content,
149            model,
150            finish_reason,
151            input_tokens,
152            output_tokens,
153            structured: None,
154        })
155    }
156}
157
158#[async_trait]
159impl ModelAdapter for AnthropicAdapter {
160    fn system_name(&self) -> &'static str {
161        "anthropic"
162    }
163
164    fn default_model(&self) -> &str {
165        &self.default_model
166    }
167
168    #[instrument(skip(self, request), fields(
169        gen_ai.system = "anthropic",
170        gen_ai.request.model = tracing::field::Empty,
171        gen_ai.usage.input_tokens = tracing::field::Empty,
172        gen_ai.usage.output_tokens = tracing::field::Empty,
173    ))]
174    async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
175        let model = request
176            .config
177            .model
178            .as_deref()
179            .unwrap_or(&self.default_model)
180            .to_string();
181        tracing::Span::current().record("gen_ai.request.model", model.as_str());
182
183        debug!(model = %model, "Calling Anthropic Messages API");
184
185        let body = self.build_request_body(&request.messages, &request.config);
186        let resp_json = self.call_api(body).await?;
187        let response = self.parse_response(resp_json)?;
188
189        tracing::Span::current()
190            .record("gen_ai.usage.input_tokens", response.input_tokens)
191            .record("gen_ai.usage.output_tokens", response.output_tokens);
192
193        Ok(response)
194    }
195
196    #[instrument(skip(self, request), fields(
197        gen_ai.system = "anthropic",
198        gen_ai.request.model = tracing::field::Empty,
199    ))]
200    async fn structured_output(
201        &self,
202        request: StructuredRequest,
203    ) -> Result<ModelResponse, ModelError> {
204        let model = request
205            .config
206            .model
207            .as_deref()
208            .unwrap_or(&self.default_model)
209            .to_string();
210        tracing::Span::current().record("gen_ai.request.model", model.as_str());
211
212        // Append schema instruction to system prompt.
213        let schema_str = serde_json::to_string_pretty(&request.output_schema)
214            .map_err(|e| ModelError::Serialization(e.to_string()))?;
215        let mut config = request.config.clone();
216        let system = config.system_prompt.get_or_insert_with(String::new);
217        system.push_str(&format!(
218            "\n\nRespond ONLY with a valid JSON object matching this schema:\n{schema_str}\nDo not include any other text."
219        ));
220
221        let chat_req = ModelRequest {
222            messages: request.messages,
223            config,
224        };
225        let mut response = self.chat(chat_req).await?;
226
227        // Parse structured output from the response content.
228        let structured = serde_json::from_str::<Value>(&response.content)
229            .or_else(|_| {
230                // Try to extract JSON from markdown code blocks.
231                let trimmed = response.content.trim();
232                let inner = trimmed
233                    .trim_start_matches("```json")
234                    .trim_start_matches("```")
235                    .trim_end_matches("```")
236                    .trim();
237                serde_json::from_str::<Value>(inner)
238            })
239            .map_err(|e| {
240                ModelError::Serialization(format!("failed to parse structured output: {e}"))
241            })?;
242
243        response.structured = Some(structured);
244        Ok(response)
245    }
246}