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