jamjet_models/
anthropic.rs1use 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
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 raw_model = config.model.as_deref().unwrap_or(&self.default_model);
83 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 let system_prompt = config.system_prompt.as_deref().or_else(|| {
90 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", };
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 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 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 let structured = serde_json::from_str::<Value>(&response.content)
240 .or_else(|_| {
241 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}