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
18const 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 request.temperature = None;
97 request.top_p = None;
98 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 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())); 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 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 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}