1use super::mappers::{map_messages, map_tools};
2use super::streaming::process_anthropic_stream;
3use super::types::{Request, Thinking};
4use crate::provider::{LlmResponseStream, ProviderFactory, StreamingModelProvider, get_context_window};
5use crate::{Context, LlmError, ProviderAuthMode, ProviderConnectionConfig, ReasoningEffort, Result};
6use async_stream;
7use eventsource_stream::Eventsource;
8use futures::StreamExt;
9use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
10use reqwest::{Client, header};
11use std::env;
12use std::time::Duration;
13use tracing::debug;
14
15#[derive(Clone)]
16pub struct AnthropicProvider {
17 client: Client,
18 model: String,
19 base_url: Option<String>,
20 auth_mode: ProviderAuthMode,
21 temperature: Option<f32>,
22 max_tokens: u32,
23 api_key: Option<String>,
24}
25
26impl AnthropicProvider {
27 pub fn new(api_key: Option<String>) -> Result<Self> {
28 let client = build_client()?;
29
30 Ok(Self {
31 client,
32 model: "claude-sonnet-4-5-20250929".to_string(),
33 base_url: Some("https://api.anthropic.com".to_string()),
34 auth_mode: ProviderAuthMode::Default,
35 temperature: None,
36 max_tokens: 16_384,
37 api_key,
38 })
39 }
40
41 pub fn with_model(mut self, model: &str) -> Self {
42 self.model = model.to_string();
43 self
44 }
45
46 pub fn with_base_url(mut self, base_url: &str) -> Self {
47 self.base_url = Some(base_url.to_string());
48 self
49 }
50
51 pub fn with_connection(mut self, connection: ProviderConnectionConfig) -> Self {
52 if let Some(base_url) = connection.base_url {
53 self.base_url = Some(base_url);
54 }
55 self.auth_mode = connection.auth_mode;
56 self
57 }
58
59 pub fn with_temperature(mut self, temperature: f32) -> Self {
60 self.temperature = Some(temperature);
61 self
62 }
63
64 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
65 self.max_tokens = max_tokens;
66 self
67 }
68
69 pub(crate) fn build_request(&self, context: &Context) -> Result<Request> {
70 let (system_prompt, messages) = map_messages(context.messages())?;
71 let tools = if context.tools().is_empty() { None } else { Some(map_tools(context.tools())?) };
72
73 let mut request = Request::new(self.model.clone(), messages)
74 .with_max_tokens(self.max_tokens)
75 .with_stream(true)
76 .with_auto_caching();
77
78 if let Some(temp) = self.temperature {
79 request = request.with_temperature(temp);
80 }
81
82 if let Some(system) = system_prompt {
83 request = request.with_system_cached(system);
84 }
85
86 if let Some(tools) = tools {
87 request = request.with_tools(tools);
88 }
89
90 if let Some(effort) = context.reasoning_effort() {
91 let budget_tokens = effort_to_budget_tokens(effort);
92 request = request.with_thinking(Thinking::new(budget_tokens));
93 request.temperature = None;
95 if request.max_tokens <= budget_tokens {
97 request.max_tokens = budget_tokens + 1024;
98 }
99 }
100
101 debug!("Built Anthropic request for model: {}", request.model);
102 Ok(request)
103 }
104
105 fn get_api_key(&self) -> Result<String> {
106 if let Some(key) = &self.api_key {
107 return Ok(key.clone());
108 }
109
110 if let Ok(api_key) = env::var("ANTHROPIC_API_KEY") {
111 return Ok(api_key);
112 }
113
114 Err(LlmError::MissingApiKey(
115 "No Anthropic credentials found. Set ANTHROPIC_API_KEY environment variable.".to_string(),
116 ))
117 }
118
119 fn build_headers(&self) -> Result<HeaderMap> {
120 let mut headers = HeaderMap::new();
121 headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
122 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
123 if self.auth_mode != ProviderAuthMode::None {
124 let api_key = self.get_api_key()?;
125 headers.insert("x-api-key", HeaderValue::from_str(&api_key)?);
126 }
127 Ok(headers)
128 }
129
130 async fn send_request(
131 &self,
132 request: Request,
133 headers: header::HeaderMap,
134 ) -> Result<impl futures::Stream<Item = Result<String>>> {
135 let base_url = self.base_url.as_deref().unwrap_or("https://api.anthropic.com");
136 let url = format!("{base_url}/v1/messages");
137
138 debug!("Sending request to Anthropic API: {url}");
139 debug!(
140 "Anthropic request body: {}",
141 serde_json::to_string(&request).unwrap_or_else(|_| "<failed to serialize>".to_string())
142 );
143
144 debug!("Anthropic request headers: {}", format_headers(&headers));
145 let response = self.client.post(&url).headers(headers).json(&request).send().await?;
146
147 if !response.status().is_success() {
148 let status = response.status();
149 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
150 let message = format!("Anthropic API request failed with status {status}: {error_text}");
151 return Err(match status.as_u16() {
152 429 => LlmError::RateLimited(message),
153 s if (500..600).contains(&s) => LlmError::ServerError { status: Some(s), message },
154 _ => LlmError::ApiError(message),
155 });
156 }
157
158 let event_stream = response.bytes_stream().eventsource();
159 let processed_stream = event_stream.filter_map(|result| {
160 std::future::ready(match result {
161 Ok(event) => {
162 let data = event.data;
163 if data == "[DONE]" { None } else { Some(Ok(data)) }
164 }
165 Err(e) => Some(Err(LlmError::StreamInterrupted(e.to_string()))),
166 })
167 });
168
169 Ok(processed_stream)
170 }
171}
172
173impl ProviderFactory for AnthropicProvider {
174 async fn from_env() -> Result<Self> {
175 Self::new(None)
176 }
177
178 async fn from_env_with_connection(connection: ProviderConnectionConfig) -> Result<Self> {
179 Ok(Self::new(None)?.with_connection(connection))
180 }
181
182 fn with_model(self, model: &str) -> Self {
183 self.with_model(model)
184 }
185}
186
187impl StreamingModelProvider for AnthropicProvider {
188 fn model(&self) -> Option<crate::LlmModel> {
189 format!("anthropic:{}", self.model).parse().ok()
190 }
191
192 fn context_window(&self) -> Option<u32> {
193 get_context_window("anthropic", &self.model)
194 }
195
196 fn stream_response<'a>(&self, context: &Context) -> LlmResponseStream {
197 let provider = self.clone();
198 let context = context.clone();
199
200 Box::pin(async_stream::stream! {
201 let headers = match provider.build_headers() {
202 Ok(result) => result,
203 Err(e) => {
204 yield Err(e);
205 return;
206 }
207 };
208
209 let request = match provider.build_request(&context) {
210 Ok(req) => req,
211 Err(e) => {
212 yield Err(e);
213 return;
214 }
215 };
216
217 let stream = match provider.send_request(request, headers).await {
218 Ok(stream) => stream,
219 Err(e) => {
220 yield Err(e);
221 return;
222 }
223 };
224
225 let mut anthropic_stream = Box::pin(process_anthropic_stream(stream));
226 while let Some(result) = anthropic_stream.next().await {
227 yield result;
228 }
229 })
230 }
231
232 fn display_name(&self) -> String {
233 format!("Anthropic ({})", self.model)
234 }
235}
236
237fn build_client() -> Result<Client> {
238 Client::builder().timeout(Duration::from_mins(1)).build().map_err(|e| LlmError::HttpClientCreation(e.to_string()))
239}
240
241fn effort_to_budget_tokens(effort: ReasoningEffort) -> u32 {
242 match effort {
243 ReasoningEffort::Low => 1024,
244 ReasoningEffort::Medium => 4096,
245 ReasoningEffort::High | ReasoningEffort::Xhigh => 10240,
246 }
247}
248
249fn should_redact_header(name: &str) -> bool {
250 let lower = name.to_ascii_lowercase();
251 lower == "authorization" || lower == "x-api-key" || lower.contains("secret") || lower.contains("token")
252}
253
254fn format_headers(headers: &header::HeaderMap) -> String {
255 let mut parts = Vec::new();
256 for (name, value) in headers {
257 let name_str = name.as_str();
258 let value_str = if should_redact_header(name_str) {
259 "<redacted>".to_string()
260 } else {
261 value.to_str().unwrap_or("<non-utf8>").to_string()
262 };
263 parts.push(format!("{name_str}={value_str}"));
264 }
265 parts.join(", ")
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::ChatMessage;
272 use crate::ContentBlock;
273 use crate::ToolDefinition;
274 use crate::providers::anthropic::types::{SystemContent, SystemContentBlock};
275 use crate::types::IsoString;
276 use reqwest::header::AUTHORIZATION;
277
278 fn create_test_provider() -> AnthropicProvider {
279 AnthropicProvider::new(Some("test-api-key".to_string()))
280 .unwrap()
281 .with_model("claude-sonnet-4-5-20250929")
282 .with_temperature(0.7)
283 .with_max_tokens(1000)
284 }
285
286 #[test]
287 fn test_provider_creation() {
288 let provider = AnthropicProvider::new(Some("test-api-key".to_string()));
289 assert!(provider.is_ok());
290 }
291
292 #[test]
293 fn build_headers_uses_api_key() {
294 let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap();
295 let headers = provider.build_headers().expect("headers");
296 assert_eq!(headers.get("x-api-key").and_then(|value| value.to_str().ok()), Some("test-api-key"));
297 assert!(headers.get(AUTHORIZATION).is_none());
298 assert!(headers.get("anthropic-beta").is_none());
299 }
300
301 #[test]
302 fn build_headers_skips_api_key_when_auth_is_none() {
303 let provider = AnthropicProvider::new(None)
304 .unwrap()
305 .with_connection(ProviderConnectionConfig { auth_mode: ProviderAuthMode::None, ..Default::default() });
306 let headers = provider.build_headers().expect("headers");
307 assert!(headers.get("x-api-key").is_none());
308 assert_eq!(headers.get("anthropic-version").and_then(|value| value.to_str().ok()), Some("2023-06-01"));
309 }
310
311 #[test]
312 fn test_build_request_simple() {
313 let provider = create_test_provider();
314
315 let context = Context::new(
316 vec![ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() }],
317 vec![],
318 );
319
320 let request = provider.build_request(&context).unwrap();
321 assert_eq!(request.model, "claude-sonnet-4-5-20250929");
322 assert_eq!(request.max_tokens, 1000);
323 assert_eq!(request.messages.len(), 1);
324 assert!(request.tools.is_none());
325 assert!(request.stream);
326 }
327
328 #[test]
329 fn test_build_request_with_system_and_tools() {
330 let provider = create_test_provider();
331
332 let context = Context::new(
333 vec![
334 ChatMessage::System { content: "You are helpful".to_string(), timestamp: IsoString::now() },
335 ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() },
336 ],
337 vec![ToolDefinition {
338 name: "search".to_string(),
339 description: "Search for information".to_string(),
340 parameters: r#"{"type": "object", "properties": {"query": {"type": "string"}}}"#.to_string(),
341 server: None,
342 }],
343 );
344
345 let request = provider.build_request(&context).unwrap();
346 if let Some(system) = &request.system {
347 match system {
348 SystemContent::Blocks(blocks) => {
349 assert_eq!(blocks.len(), 1);
350 let SystemContentBlock::Text { text, .. } = &blocks[0];
351 assert_eq!(text, "You are helpful");
352 }
353 SystemContent::Text(_) => panic!("Expected blocks system content"),
354 }
355 } else {
356 panic!("Expected system prompt");
357 }
358 assert_eq!(request.messages.len(), 1);
359 assert!(request.tools.is_some());
360 assert_eq!(request.tools.unwrap().len(), 1);
361 }
362
363 #[test]
364 fn test_build_request_with_caching() {
365 let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap(); let context = Context::new(
368 vec![
369 ChatMessage::System { content: "Hello".to_string(), timestamp: IsoString::now() },
370 ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() },
371 ],
372 vec![ToolDefinition {
373 name: "search".to_string(),
374 description: "Search for information".to_string(),
375 parameters: r#"{"type": "object", "properties": {"query": {"type": "string"}}}"#.to_string(),
376 server: None,
377 }],
378 );
379
380 let request = provider.build_request(&context).unwrap();
381
382 if let Some(system) = &request.system {
384 match system {
385 SystemContent::Blocks(blocks) => {
386 assert_eq!(blocks.len(), 1);
387 let SystemContentBlock::Text { text, cache_control } = &blocks[0];
388 assert_eq!(text, "Hello");
389 assert!(cache_control.is_some());
390 }
391 SystemContent::Text(_) => panic!("Expected blocks system content for caching"),
392 }
393 } else {
394 panic!("Expected system prompt");
395 }
396
397 assert!(request.tools.is_some());
398
399 assert!(request.cache_control.is_some());
401 }
402
403 #[test]
404 fn test_build_request_with_reasoning_effort() {
405 let provider = create_test_provider();
406
407 let mut context = Context::new(
408 vec![ChatMessage::User { content: vec![ContentBlock::text("Think hard")], timestamp: IsoString::now() }],
409 vec![],
410 );
411 context.set_reasoning_effort(Some(crate::ReasoningEffort::High));
412
413 let request = provider.build_request(&context).unwrap();
414 let thinking = request.thinking.expect("thinking should be set");
415 assert_eq!(thinking.thinking_type, "enabled");
416 assert_eq!(thinking.budget_tokens, 10240);
417 assert!(request.temperature.is_none());
419 assert!(request.max_tokens > thinking.budget_tokens);
421 }
422
423 #[test]
424 fn test_build_request_without_reasoning_effort_has_no_thinking() {
425 let provider = create_test_provider();
426 let context = Context::new(
427 vec![ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() }],
428 vec![],
429 );
430
431 let request = provider.build_request(&context).unwrap();
432 assert!(request.thinking.is_none());
433 }
434
435 #[test]
436 fn test_build_request_thinking_bumps_max_tokens_if_needed() {
437 let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap().with_max_tokens(500);
438
439 let mut context = Context::new(
440 vec![ChatMessage::User { content: vec![ContentBlock::text("Hi")], timestamp: IsoString::now() }],
441 vec![],
442 );
443 context.set_reasoning_effort(Some(crate::ReasoningEffort::Low));
444
445 let request = provider.build_request(&context).unwrap();
446 let thinking = request.thinking.as_ref().unwrap();
447 assert!(
448 request.max_tokens > thinking.budget_tokens,
449 "max_tokens ({}) should exceed budget_tokens ({})",
450 request.max_tokens,
451 thinking.budget_tokens
452 );
453 }
454
455 #[test]
456 fn test_anthropic_provider_display_name() {
457 let provider = create_test_provider();
458 assert_eq!(provider.display_name(), "Anthropic (claude-sonnet-4-5-20250929)");
459 }
460
461 #[test]
462 fn test_anthropic_provider_display_name_default() {
463 let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap();
464 assert_eq!(provider.display_name(), "Anthropic (claude-sonnet-4-5-20250929)");
465 }
466
467 #[test]
468 fn format_headers_redacts_x_api_key() {
469 let mut headers = HeaderMap::new();
470 headers.insert("x-api-key", HeaderValue::from_static("sk-secret-123"));
471 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
472
473 let formatted = format_headers(&headers);
474 assert!(formatted.contains("x-api-key=<redacted>"));
475 assert!(formatted.contains("content-type=application/json"));
476 assert!(!formatted.contains("sk-secret-123"));
477 }
478
479 #[test]
480 fn format_headers_redacts_authorization() {
481 let mut headers = HeaderMap::new();
482 headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer token123"));
483
484 let formatted = format_headers(&headers);
485 assert!(formatted.contains("authorization=<redacted>"));
486 assert!(!formatted.contains("token123"));
487 }
488
489 #[test]
490 fn format_headers_redacts_secret_and_token_headers() {
491 let mut headers = HeaderMap::new();
492 headers.insert("x-client-secret", HeaderValue::from_static("mysecret"));
493 headers.insert("x-auth-token", HeaderValue::from_static("mytoken"));
494 headers.insert("accept", HeaderValue::from_static("text/plain"));
495
496 let formatted = format_headers(&headers);
497 assert!(formatted.contains("x-client-secret=<redacted>"));
498 assert!(formatted.contains("x-auth-token=<redacted>"));
499 assert!(formatted.contains("accept=text/plain"));
500 assert!(!formatted.contains("mysecret"));
501 assert!(!formatted.contains("mytoken"));
502 }
503}