Skip to main content

llm/providers/anthropic/
provider.rs

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/// Tokens requested when no `max_tokens` is set via `ModelSettings`. Anthropic requires
16/// `max_tokens` on every request, so a value is always sent.
17const 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            // Anthropic requires temperature and top_p to be unset when thinking is enabled
90            request.temperature = None;
91            request.top_p = None;
92            // max_tokens must be > budget_tokens
93            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        // 1024 is the Anthropic API's minimum thinking budget.
241        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(); // Caching is enabled by default
360
361        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        // With caching enabled, system prompt should be cached
376        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        // Top-level cache_control enables automatic caching
393        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        // Temperature must be unset when thinking is enabled
411        assert!(request.temperature.is_none());
412        // max_tokens must exceed budget_tokens
413        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}