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