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#[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            // Anthropic requires temperature to be unset when thinking is enabled
94            request.temperature = None;
95            // max_tokens must be > budget_tokens
96            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::new(
338                "search",
339                "Search for information",
340                r#"{"type": "object", "properties": {"query": {"type": "string"}}}"#,
341            )],
342        );
343
344        let request = provider.build_request(&context).unwrap();
345        if let Some(system) = &request.system {
346            match system {
347                SystemContent::Blocks(blocks) => {
348                    assert_eq!(blocks.len(), 1);
349                    let SystemContentBlock::Text { text, .. } = &blocks[0];
350                    assert_eq!(text, "You are helpful");
351                }
352                SystemContent::Text(_) => panic!("Expected blocks system content"),
353            }
354        } else {
355            panic!("Expected system prompt");
356        }
357        assert_eq!(request.messages.len(), 1);
358        assert!(request.tools.is_some());
359        assert_eq!(request.tools.unwrap().len(), 1);
360    }
361
362    #[test]
363    fn test_build_request_with_caching() {
364        let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap(); // Caching is enabled by default
365
366        let context = Context::new(
367            vec![
368                ChatMessage::System { content: "Hello".to_string(), timestamp: IsoString::now() },
369                ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() },
370            ],
371            vec![ToolDefinition::new(
372                "search",
373                "Search for information",
374                r#"{"type": "object", "properties": {"query": {"type": "string"}}}"#,
375            )],
376        );
377
378        let request = provider.build_request(&context).unwrap();
379
380        // With caching enabled, system prompt should be cached
381        if let Some(system) = &request.system {
382            match system {
383                SystemContent::Blocks(blocks) => {
384                    assert_eq!(blocks.len(), 1);
385                    let SystemContentBlock::Text { text, cache_control } = &blocks[0];
386                    assert_eq!(text, "Hello");
387                    assert!(cache_control.is_some());
388                }
389                SystemContent::Text(_) => panic!("Expected blocks system content for caching"),
390            }
391        } else {
392            panic!("Expected system prompt");
393        }
394
395        assert!(request.tools.is_some());
396
397        // Top-level cache_control enables automatic caching
398        assert!(request.cache_control.is_some());
399    }
400
401    #[test]
402    fn test_build_request_with_reasoning_effort() {
403        let provider = create_test_provider();
404
405        let mut context = Context::new(
406            vec![ChatMessage::User { content: vec![ContentBlock::text("Think hard")], timestamp: IsoString::now() }],
407            vec![],
408        );
409        context.set_reasoning_effort(Some(crate::ReasoningEffort::High));
410
411        let request = provider.build_request(&context).unwrap();
412        let thinking = request.thinking.expect("thinking should be set");
413        assert_eq!(thinking.thinking_type, "enabled");
414        assert_eq!(thinking.budget_tokens, 10240);
415        // Temperature must be unset when thinking is enabled
416        assert!(request.temperature.is_none());
417        // max_tokens must exceed budget_tokens
418        assert!(request.max_tokens > thinking.budget_tokens);
419    }
420
421    #[test]
422    fn test_build_request_without_reasoning_effort_has_no_thinking() {
423        let provider = create_test_provider();
424        let context = Context::new(
425            vec![ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() }],
426            vec![],
427        );
428
429        let request = provider.build_request(&context).unwrap();
430        assert!(request.thinking.is_none());
431    }
432
433    #[test]
434    fn test_build_request_thinking_bumps_max_tokens_if_needed() {
435        let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap().with_max_tokens(500);
436
437        let mut context = Context::new(
438            vec![ChatMessage::User { content: vec![ContentBlock::text("Hi")], timestamp: IsoString::now() }],
439            vec![],
440        );
441        context.set_reasoning_effort(Some(crate::ReasoningEffort::Low));
442
443        let request = provider.build_request(&context).unwrap();
444        let thinking = request.thinking.as_ref().unwrap();
445        assert!(
446            request.max_tokens > thinking.budget_tokens,
447            "max_tokens ({}) should exceed budget_tokens ({})",
448            request.max_tokens,
449            thinking.budget_tokens
450        );
451    }
452
453    #[test]
454    fn test_anthropic_provider_display_name() {
455        let provider = create_test_provider();
456        assert_eq!(provider.display_name(), "Anthropic (claude-sonnet-4-5-20250929)");
457    }
458
459    #[test]
460    fn test_anthropic_provider_display_name_default() {
461        let provider = AnthropicProvider::new(Some("test-api-key".to_string())).unwrap();
462        assert_eq!(provider.display_name(), "Anthropic (claude-sonnet-4-5-20250929)");
463    }
464
465    #[test]
466    fn format_headers_redacts_x_api_key() {
467        let mut headers = HeaderMap::new();
468        headers.insert("x-api-key", HeaderValue::from_static("sk-secret-123"));
469        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
470
471        let formatted = format_headers(&headers);
472        assert!(formatted.contains("x-api-key=<redacted>"));
473        assert!(formatted.contains("content-type=application/json"));
474        assert!(!formatted.contains("sk-secret-123"));
475    }
476
477    #[test]
478    fn format_headers_redacts_authorization() {
479        let mut headers = HeaderMap::new();
480        headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer token123"));
481
482        let formatted = format_headers(&headers);
483        assert!(formatted.contains("authorization=<redacted>"));
484        assert!(!formatted.contains("token123"));
485    }
486
487    #[test]
488    fn format_headers_redacts_secret_and_token_headers() {
489        let mut headers = HeaderMap::new();
490        headers.insert("x-client-secret", HeaderValue::from_static("mysecret"));
491        headers.insert("x-auth-token", HeaderValue::from_static("mytoken"));
492        headers.insert("accept", HeaderValue::from_static("text/plain"));
493
494        let formatted = format_headers(&headers);
495        assert!(formatted.contains("x-client-secret=<redacted>"));
496        assert!(formatted.contains("x-auth-token=<redacted>"));
497        assert!(formatted.contains("accept=text/plain"));
498        assert!(!formatted.contains("mysecret"));
499        assert!(!formatted.contains("mytoken"));
500    }
501}