Skip to main content

llm/providers/anthropic/
provider.rs

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