Skip to main content

anthropic_async/
config.rs

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