Skip to main content

anthropic_async/
config.rs

1use reqwest::header::AUTHORIZATION;
2use reqwest::header::HeaderMap;
3use reqwest::header::HeaderValue;
4use secrecy::ExposeSecret;
5use secrecy::SecretString;
6use serde::Deserialize;
7
8/// Default Anthropic API base URL
9pub const ANTHROPIC_DEFAULT_BASE: &str = "https://api.anthropic.com";
10/// Default Anthropic API version
11pub const ANTHROPIC_VERSION: &str = "2023-06-01";
12/// Header name for Anthropic version
13pub const HDR_ANTHROPIC_VERSION: &str = "anthropic-version";
14/// Header name for Anthropic beta features
15pub const HDR_ANTHROPIC_BETA: &str = "anthropic-beta";
16/// Header name for API key authentication
17pub const HDR_X_API_KEY: &str = "x-api-key";
18
19/// Authentication method for Anthropic API
20///
21/// Debug output automatically redacts credentials via [`SecretString`].
22#[derive(Clone, Debug)]
23pub enum AnthropicAuth {
24    /// API key authentication
25    ApiKey(SecretString),
26    /// Bearer token authentication
27    Bearer(SecretString),
28    /// Both API key and bearer token authentication
29    Both {
30        /// API key for x-api-key header
31        api_key: SecretString,
32        /// Bearer token for Authorization header
33        bearer: SecretString,
34    },
35    /// No authentication configured
36    None,
37}
38
39/// Configuration for the Anthropic client
40///
41/// Debug output automatically redacts credentials via [`SecretString`] in [`AnthropicAuth`].
42#[derive(Clone, Debug, Deserialize)]
43#[serde(default)]
44pub struct AnthropicConfig {
45    api_base: String,
46    version: String,
47    #[serde(skip)]
48    auth: AnthropicAuth,
49    #[serde(skip)]
50    beta: Vec<String>,
51    /// Skip auth validation (for proxy/testing scenarios where auth is handled externally)
52    #[serde(skip)]
53    dangerously_skip_auth: bool,
54}
55
56/// Helper to read and normalize an env var (trim + filter empty).
57fn env_trimmed(name: &str) -> Option<String> {
58    std::env::var(name)
59        .ok()
60        .map(|v| v.trim().to_string())
61        .filter(|v| !v.is_empty())
62}
63
64impl Default for AnthropicConfig {
65    fn default() -> Self {
66        let api_key = env_trimmed("ANTHROPIC_API_KEY").map(SecretString::from);
67        let bearer = env_trimmed("ANTHROPIC_AUTH_TOKEN").map(SecretString::from);
68        let api_base =
69            env_trimmed("ANTHROPIC_BASE_URL").unwrap_or_else(|| ANTHROPIC_DEFAULT_BASE.into());
70
71        let auth = match (api_key, bearer) {
72            (Some(k), Some(t)) => AnthropicAuth::Both {
73                api_key: k,
74                bearer: t,
75            },
76            (Some(k), None) => AnthropicAuth::ApiKey(k),
77            (None, Some(t)) => AnthropicAuth::Bearer(t),
78            _ => AnthropicAuth::None,
79        };
80
81        Self {
82            api_base,
83            version: ANTHROPIC_VERSION.into(),
84            auth,
85            beta: vec![],
86            dangerously_skip_auth: false,
87        }
88    }
89}
90
91impl AnthropicConfig {
92    /// Creates a new configuration with default settings
93    ///
94    /// Attempts to read from environment variables:
95    /// - `ANTHROPIC_API_KEY` for API key authentication
96    /// - `ANTHROPIC_AUTH_TOKEN` for bearer token authentication
97    /// - `ANTHROPIC_BASE_URL` for custom API base URL (defaults to `https://api.anthropic.com`)
98    #[must_use]
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Sets the API base URL
104    ///
105    /// Default is `https://api.anthropic.com`
106    #[must_use]
107    pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
108        self.api_base = base.into();
109        self
110    }
111
112    /// Sets the Anthropic API version
113    ///
114    /// Default is `2023-06-01`
115    #[must_use]
116    pub fn with_version(mut self, v: impl Into<String>) -> Self {
117        self.version = v.into();
118        self
119    }
120
121    /// Sets API key authentication
122    ///
123    /// This will use the `x-api-key` header for authentication.
124    #[must_use]
125    pub fn with_api_key(mut self, k: impl Into<String>) -> Self {
126        self.auth = AnthropicAuth::ApiKey(SecretString::from(k.into()));
127        self
128    }
129
130    /// Sets bearer token authentication
131    ///
132    /// This will use the `Authorization: Bearer` header for authentication.
133    #[must_use]
134    pub fn with_bearer(mut self, t: impl Into<String>) -> Self {
135        self.auth = AnthropicAuth::Bearer(SecretString::from(t.into()));
136        self
137    }
138
139    /// Sets both API key and bearer token authentication
140    ///
141    /// This will send both the `x-api-key` and `Authorization: Bearer` headers.
142    /// This matches the behavior of the official Python SDK when both credentials are present.
143    #[must_use]
144    pub fn with_both(mut self, api_key: impl Into<String>, bearer: impl Into<String>) -> Self {
145        self.auth = AnthropicAuth::Both {
146            api_key: SecretString::from(api_key.into()),
147            bearer: SecretString::from(bearer.into()),
148        };
149        self
150    }
151
152    /// Sets custom beta feature strings
153    ///
154    /// These will be sent in the `anthropic-beta` header as a comma-separated list.
155    #[must_use]
156    pub fn with_beta<I, S>(mut self, beta: I) -> Self
157    where
158        I: IntoIterator<Item = S>,
159        S: Into<String>,
160    {
161        self.beta = beta.into_iter().map(Into::into).collect();
162        self
163    }
164
165    /// Disable sending auth headers and skip auth validation.
166    ///
167    /// Use only when authentication is injected by an upstream proxy/gateway
168    /// (e.g., during recording tests or in a service mesh).
169    #[must_use]
170    pub fn dangerously_skip_auth(mut self) -> Self {
171        self.dangerously_skip_auth = true;
172        self.auth = AnthropicAuth::None;
173        self
174    }
175
176    /// Returns the configured API base URL
177    #[must_use]
178    pub fn api_base(&self) -> &str {
179        &self.api_base
180    }
181
182    /// Validates that authentication credentials are present and non-empty.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if neither API key nor bearer token is configured,
187    /// or if the configured credentials are empty/whitespace-only.
188    /// Returns `Ok(())` if `dangerously_skip_auth()` was called.
189    pub fn validate_auth(&self) -> Result<(), crate::error::AnthropicError> {
190        use crate::error::AnthropicError;
191
192        // Skip validation if auth is handled externally (proxy/gateway)
193        if self.dangerously_skip_auth {
194            return Ok(());
195        }
196
197        match &self.auth {
198            AnthropicAuth::ApiKey(k) if !k.expose_secret().trim().is_empty() => Ok(()),
199            AnthropicAuth::Bearer(t) if !t.expose_secret().trim().is_empty() => Ok(()),
200            AnthropicAuth::Both { api_key, bearer }
201                if !api_key.expose_secret().trim().is_empty()
202                    && !bearer.expose_secret().trim().is_empty() =>
203            {
204                Ok(())
205            }
206            _ => Err(AnthropicError::Config(
207                "Missing Anthropic credentials: set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN"
208                    .into(),
209            )),
210        }
211    }
212
213    /// Sets beta features using the `BetaFeature` enum
214    ///
215    /// This is a type-safe alternative to [`with_beta`](Self::with_beta).
216    #[must_use]
217    pub fn with_beta_features<I: IntoIterator<Item = BetaFeature>>(mut self, features: I) -> Self {
218        self.beta = features.into_iter().map(Into::<String>::into).collect();
219        self
220    }
221}
222
223/// Configuration trait for the Anthropic client
224///
225/// Implement this trait to provide custom authentication and API configuration.
226pub trait Config: Send + Sync {
227    /// Returns HTTP headers to include in requests
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if header values contain invalid characters.
232    fn headers(&self) -> Result<HeaderMap, crate::error::AnthropicError>;
233
234    /// Constructs the full URL for an API endpoint
235    fn url(&self, path: &str) -> String;
236
237    /// Returns query parameters to include in requests
238    fn query(&self) -> Vec<(&str, &str)>;
239
240    /// Validates that authentication credentials are present.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if authentication is not properly configured.
245    fn validate_auth(&self) -> Result<(), crate::error::AnthropicError>;
246}
247
248impl Config for AnthropicConfig {
249    fn headers(&self) -> Result<HeaderMap, crate::error::AnthropicError> {
250        use crate::error::AnthropicError;
251
252        let mut h = HeaderMap::new();
253
254        h.insert(
255            HDR_ANTHROPIC_VERSION,
256            HeaderValue::from_str(&self.version)
257                .map_err(|_| AnthropicError::Config("Invalid anthropic-version header".into()))?,
258        );
259
260        if !self.beta.is_empty() {
261            let v = self.beta.join(",");
262            h.insert(
263                HDR_ANTHROPIC_BETA,
264                HeaderValue::from_str(&v)
265                    .map_err(|_| AnthropicError::Config("Invalid anthropic-beta header".into()))?,
266            );
267        }
268
269        // Skip auth headers if auth is handled externally (proxy/gateway)
270        if !self.dangerously_skip_auth {
271            match &self.auth {
272                AnthropicAuth::ApiKey(k) => {
273                    h.insert(
274                        HDR_X_API_KEY,
275                        HeaderValue::from_str(k.expose_secret()).map_err(|_| {
276                            AnthropicError::Config("Invalid x-api-key value".into())
277                        })?,
278                    );
279                }
280                AnthropicAuth::Bearer(t) => {
281                    let v = format!("Bearer {}", t.expose_secret());
282                    h.insert(
283                        AUTHORIZATION,
284                        HeaderValue::from_str(&v).map_err(|_| {
285                            AnthropicError::Config("Invalid Authorization header".into())
286                        })?,
287                    );
288                }
289                AnthropicAuth::Both { api_key, bearer } => {
290                    h.insert(
291                        HDR_X_API_KEY,
292                        HeaderValue::from_str(api_key.expose_secret()).map_err(|_| {
293                            AnthropicError::Config("Invalid x-api-key value".into())
294                        })?,
295                    );
296                    let v = format!("Bearer {}", bearer.expose_secret());
297                    h.insert(
298                        AUTHORIZATION,
299                        HeaderValue::from_str(&v).map_err(|_| {
300                            AnthropicError::Config("Invalid Authorization header".into())
301                        })?,
302                    );
303                }
304                AnthropicAuth::None => {}
305            }
306        }
307
308        Ok(h)
309    }
310
311    fn url(&self, path: &str) -> String {
312        format!("{}{}", self.api_base, path)
313    }
314
315    fn query(&self) -> Vec<(&str, &str)> {
316        vec![]
317    }
318
319    fn validate_auth(&self) -> Result<(), crate::error::AnthropicError> {
320        self.validate_auth()
321    }
322}
323
324/// Known Anthropic beta features
325///
326/// See the [Anthropic API documentation](https://docs.anthropic.com/en/api) for details on each feature.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum BetaFeature {
329    /// Prompt caching (2024-07-31)
330    PromptCaching20240731,
331    /// Extended cache TTL (2025-04-11)
332    ExtendedCacheTtl20250411,
333    /// Token counting (2024-11-01)
334    TokenCounting20241101,
335    /// Structured outputs (2025-09-17) — Python SDK version
336    StructuredOutputs20250917,
337    /// Structured outputs (2025-11-13) — TypeScript SDK version (recommended)
338    StructuredOutputs20251113,
339    /// Alias to the latest structured outputs beta (currently 2025-11-13)
340    StructuredOutputsLatest,
341    /// Custom beta feature string
342    Other(String),
343}
344
345impl From<BetaFeature> for String {
346    fn from(b: BetaFeature) -> Self {
347        match b {
348            BetaFeature::PromptCaching20240731 => "prompt-caching-2024-07-31".into(),
349            BetaFeature::ExtendedCacheTtl20250411 => "extended-cache-ttl-2025-04-11".into(),
350            BetaFeature::TokenCounting20241101 => "token-counting-2024-11-01".into(),
351            BetaFeature::StructuredOutputs20250917 => "structured-outputs-2025-09-17".into(),
352            BetaFeature::StructuredOutputs20251113 | BetaFeature::StructuredOutputsLatest => {
353                "structured-outputs-2025-11-13".into()
354            }
355            BetaFeature::Other(s) => s,
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_default_headers_exist() {
366        let cfg = AnthropicConfig::new();
367        let h = cfg.headers().unwrap();
368        assert!(h.contains_key(super::HDR_ANTHROPIC_VERSION));
369    }
370
371    #[test]
372    fn auth_api_key_header() {
373        let cfg = AnthropicConfig::new().with_api_key("k123");
374        let h = cfg.headers().unwrap();
375        assert!(h.contains_key(HDR_X_API_KEY));
376        assert!(!h.contains_key(reqwest::header::AUTHORIZATION));
377    }
378
379    #[test]
380    fn auth_bearer_header() {
381        let cfg = AnthropicConfig::new().with_bearer("t123");
382        let h = cfg.headers().unwrap();
383        assert!(h.contains_key(reqwest::header::AUTHORIZATION));
384        assert!(!h.contains_key(HDR_X_API_KEY));
385    }
386
387    #[test]
388    fn auth_both_headers() {
389        let cfg = AnthropicConfig::new().with_both("k123", "t123");
390        let h = cfg.headers().unwrap();
391        assert!(h.contains_key(HDR_X_API_KEY));
392        assert!(h.contains_key(reqwest::header::AUTHORIZATION));
393    }
394
395    #[test]
396    fn beta_header_join() {
397        let cfg = AnthropicConfig::new().with_beta(vec!["a", "b"]);
398        let h = cfg.headers().unwrap();
399        let v = h.get(HDR_ANTHROPIC_BETA).unwrap().to_str().unwrap();
400        assert_eq!(v, "a,b");
401    }
402
403    #[test]
404    fn invalid_header_values_error() {
405        let cfg = AnthropicConfig::new().with_api_key("bad\nkey");
406        match cfg.headers() {
407            Err(crate::error::AnthropicError::Config(msg)) => assert!(msg.contains("x-api-key")),
408            other => panic!("Expected Config error, got {other:?}"),
409        }
410    }
411
412    #[test]
413    fn validate_auth_missing() {
414        let cfg = AnthropicConfig {
415            api_base: "test".into(),
416            version: "test".into(),
417            auth: AnthropicAuth::None,
418            beta: vec![],
419            dangerously_skip_auth: false,
420        };
421        assert!(cfg.validate_auth().is_err());
422    }
423
424    #[test]
425    fn debug_output_redacts_api_key() {
426        let cfg = AnthropicConfig::new().with_api_key("super-secret-key-12345");
427        let debug_str = format!("{cfg:?}");
428
429        assert!(
430            !debug_str.contains("super-secret-key-12345"),
431            "Debug output should not contain the API key"
432        );
433        // SecretString uses [REDACTED] format
434        assert!(
435            debug_str.contains("[REDACTED]"),
436            "Debug output should contain '[REDACTED]', got: {debug_str}"
437        );
438    }
439
440    #[test]
441    fn debug_output_redacts_bearer() {
442        let cfg = AnthropicConfig::new().with_bearer("super-secret-token-12345");
443        let debug_str = format!("{cfg:?}");
444
445        assert!(
446            !debug_str.contains("super-secret-token-12345"),
447            "Debug output should not contain the bearer token"
448        );
449        // SecretString uses [REDACTED] format
450        assert!(
451            debug_str.contains("[REDACTED]"),
452            "Debug output should contain '[REDACTED]', got: {debug_str}"
453        );
454    }
455
456    #[test]
457    fn debug_output_redacts_both() {
458        let cfg = AnthropicConfig::new().with_both("secret-api-key", "secret-bearer-token");
459        let debug_str = format!("{cfg:?}");
460
461        assert!(
462            !debug_str.contains("secret-api-key"),
463            "Debug output should not contain the API key"
464        );
465        assert!(
466            !debug_str.contains("secret-bearer-token"),
467            "Debug output should not contain the bearer token"
468        );
469        // SecretString uses [REDACTED] format
470        assert!(
471            debug_str.contains("[REDACTED]"),
472            "Debug output should contain '[REDACTED]', got: {debug_str}"
473        );
474    }
475
476    #[test]
477    fn validate_auth_rejects_empty_api_key() {
478        let cfg = AnthropicConfig::new().with_api_key("");
479        assert!(cfg.validate_auth().is_err());
480
481        let cfg = AnthropicConfig::new().with_api_key("   ");
482        assert!(cfg.validate_auth().is_err());
483
484        let cfg = AnthropicConfig::new().with_api_key("\n");
485        assert!(cfg.validate_auth().is_err());
486    }
487
488    #[test]
489    fn validate_auth_rejects_empty_bearer() {
490        let cfg = AnthropicConfig::new().with_bearer("");
491        assert!(cfg.validate_auth().is_err());
492
493        let cfg = AnthropicConfig::new().with_bearer("   ");
494        assert!(cfg.validate_auth().is_err());
495    }
496
497    #[test]
498    fn validate_auth_rejects_empty_both() {
499        // Both empty
500        let cfg = AnthropicConfig::new().with_both("", "");
501        assert!(cfg.validate_auth().is_err());
502
503        // API key empty, bearer valid
504        let cfg = AnthropicConfig::new().with_both("", "valid-token");
505        assert!(cfg.validate_auth().is_err());
506
507        // API key valid, bearer empty
508        let cfg = AnthropicConfig::new().with_both("valid-key", "");
509        assert!(cfg.validate_auth().is_err());
510
511        // Both whitespace
512        let cfg = AnthropicConfig::new().with_both("   ", "   ");
513        assert!(cfg.validate_auth().is_err());
514    }
515
516    #[test]
517    fn validate_auth_accepts_valid_credentials() {
518        let cfg = AnthropicConfig::new().with_api_key("valid-key");
519        assert!(cfg.validate_auth().is_ok());
520
521        let cfg = AnthropicConfig::new().with_bearer("valid-token");
522        assert!(cfg.validate_auth().is_ok());
523
524        let cfg = AnthropicConfig::new().with_both("valid-key", "valid-token");
525        assert!(cfg.validate_auth().is_ok());
526
527        // Valid with leading/trailing whitespace (trimmed internally)
528        let cfg = AnthropicConfig::new().with_api_key("  valid-key  ");
529        assert!(cfg.validate_auth().is_ok());
530    }
531
532    #[test]
533    fn dangerously_skip_auth_bypasses_validation() {
534        // Without skip_auth, no credentials fails validation
535        let cfg_normal = AnthropicConfig {
536            api_base: "test".into(),
537            version: "test".into(),
538            auth: AnthropicAuth::None,
539            beta: vec![],
540            dangerously_skip_auth: false,
541        };
542        assert!(cfg_normal.validate_auth().is_err());
543
544        // With skip_auth, validation succeeds despite no credentials
545        let cfg_skip = AnthropicConfig::new().dangerously_skip_auth();
546        assert!(
547            cfg_skip.validate_auth().is_ok(),
548            "validate_auth must succeed when dangerously_skip_auth is set"
549        );
550    }
551
552    #[test]
553    fn dangerously_skip_auth_omits_headers() {
554        // Start with valid credentials, then skip auth ("skip wins")
555        let cfg = AnthropicConfig::new()
556            .with_both("test-key", "test-token")
557            .dangerously_skip_auth();
558
559        let headers = cfg.headers().unwrap();
560
561        // Should NOT contain auth headers
562        assert!(
563            !headers.contains_key(HDR_X_API_KEY),
564            "x-api-key must not be present when dangerously_skip_auth is set"
565        );
566        assert!(
567            !headers.contains_key(reqwest::header::AUTHORIZATION),
568            "Authorization must not be present when dangerously_skip_auth is set"
569        );
570
571        // Should still contain non-auth headers
572        assert!(headers.contains_key(HDR_ANTHROPIC_VERSION));
573    }
574}