jamjet_models/
anthropic.rs1use 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
19pub struct AnthropicAdapter {
21 client: reqwest::Client,
22 api_key: String,
23 default_model: String,
24}
25
26impl AnthropicAdapter {
27 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 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 let system_prompt = config.system_prompt.as_deref().or_else(|| {
87 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", };
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 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 let structured = serde_json::from_str::<Value>(&response.content)
229 .or_else(|_| {
230 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}