anthropic-async 0.5.2

Anthropic API client for Rust with prompt caching support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
use reqwest::header::AUTHORIZATION;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;

/// Default Anthropic API base URL
pub const ANTHROPIC_DEFAULT_BASE: &str = "https://api.anthropic.com";
/// Default Anthropic API version
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
/// Header name for Anthropic version
pub const HDR_ANTHROPIC_VERSION: &str = "anthropic-version";
/// Header name for Anthropic beta features
pub const HDR_ANTHROPIC_BETA: &str = "anthropic-beta";
/// Header name for API key authentication
pub const HDR_X_API_KEY: &str = "x-api-key";

/// Authentication method for Anthropic API
///
/// Debug output automatically redacts credentials via [`SecretString`].
#[derive(Clone, Debug)]
pub enum AnthropicAuth {
    /// API key authentication
    ApiKey(SecretString),
    /// Bearer token authentication
    Bearer(SecretString),
    /// Both API key and bearer token authentication
    Both {
        /// API key for x-api-key header
        api_key: SecretString,
        /// Bearer token for Authorization header
        bearer: SecretString,
    },
    /// No authentication configured
    None,
}

/// Configuration for the Anthropic client
///
/// Debug output automatically redacts credentials via [`SecretString`] in [`AnthropicAuth`].
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct AnthropicConfig {
    api_base: String,
    version: String,
    #[serde(skip)]
    auth: AnthropicAuth,
    #[serde(skip)]
    beta: Vec<String>,
    /// Skip auth validation (for proxy/testing scenarios where auth is handled externally)
    #[serde(skip)]
    dangerously_skip_auth: bool,
}

/// Helper to read and normalize an env var (trim + filter empty).
fn env_trimmed(name: &str) -> Option<String> {
    std::env::var(name)
        .ok()
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty())
}

impl Default for AnthropicConfig {
    fn default() -> Self {
        let api_key = env_trimmed("ANTHROPIC_API_KEY").map(SecretString::from);
        let bearer = env_trimmed("ANTHROPIC_AUTH_TOKEN").map(SecretString::from);
        let api_base =
            env_trimmed("ANTHROPIC_BASE_URL").unwrap_or_else(|| ANTHROPIC_DEFAULT_BASE.into());

        let auth = match (api_key, bearer) {
            (Some(k), Some(t)) => AnthropicAuth::Both {
                api_key: k,
                bearer: t,
            },
            (Some(k), None) => AnthropicAuth::ApiKey(k),
            (None, Some(t)) => AnthropicAuth::Bearer(t),
            _ => AnthropicAuth::None,
        };

        Self {
            api_base,
            version: ANTHROPIC_VERSION.into(),
            auth,
            beta: vec![],
            dangerously_skip_auth: false,
        }
    }
}

impl AnthropicConfig {
    /// Creates a new configuration with default settings
    ///
    /// Attempts to read from environment variables:
    /// - `ANTHROPIC_API_KEY` for API key authentication
    /// - `ANTHROPIC_AUTH_TOKEN` for bearer token authentication
    /// - `ANTHROPIC_BASE_URL` for custom API base URL (defaults to `https://api.anthropic.com`)
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the API base URL
    ///
    /// Default is `https://api.anthropic.com`
    #[must_use]
    pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
        self.api_base = base.into();
        self
    }

    /// Sets the Anthropic API version
    ///
    /// Default is `2023-06-01`
    #[must_use]
    pub fn with_version(mut self, v: impl Into<String>) -> Self {
        self.version = v.into();
        self
    }

    /// Sets API key authentication
    ///
    /// This will use the `x-api-key` header for authentication.
    #[must_use]
    pub fn with_api_key(mut self, k: impl Into<String>) -> Self {
        self.auth = AnthropicAuth::ApiKey(SecretString::from(k.into()));
        self
    }

    /// Sets bearer token authentication
    ///
    /// This will use the `Authorization: Bearer` header for authentication.
    #[must_use]
    pub fn with_bearer(mut self, t: impl Into<String>) -> Self {
        self.auth = AnthropicAuth::Bearer(SecretString::from(t.into()));
        self
    }

    /// Sets both API key and bearer token authentication
    ///
    /// This will send both the `x-api-key` and `Authorization: Bearer` headers.
    /// This matches the behavior of the official Python SDK when both credentials are present.
    #[must_use]
    pub fn with_both(mut self, api_key: impl Into<String>, bearer: impl Into<String>) -> Self {
        self.auth = AnthropicAuth::Both {
            api_key: SecretString::from(api_key.into()),
            bearer: SecretString::from(bearer.into()),
        };
        self
    }

    /// Sets custom beta feature strings
    ///
    /// These will be sent in the `anthropic-beta` header as a comma-separated list.
    #[must_use]
    pub fn with_beta<I, S>(mut self, beta: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.beta = beta.into_iter().map(Into::into).collect();
        self
    }

    /// Disable sending auth headers and skip auth validation.
    ///
    /// Use only when authentication is injected by an upstream proxy/gateway
    /// (e.g., during recording tests or in a service mesh).
    #[must_use]
    pub fn dangerously_skip_auth(mut self) -> Self {
        self.dangerously_skip_auth = true;
        self.auth = AnthropicAuth::None;
        self
    }

    /// Returns the configured API base URL
    #[must_use]
    pub fn api_base(&self) -> &str {
        &self.api_base
    }

    /// Validates that authentication credentials are present and non-empty.
    ///
    /// # Errors
    ///
    /// Returns an error if neither API key nor bearer token is configured,
    /// or if the configured credentials are empty/whitespace-only.
    /// Returns `Ok(())` if `dangerously_skip_auth()` was called.
    pub fn validate_auth(&self) -> Result<(), crate::error::AnthropicError> {
        use crate::error::AnthropicError;

        // Skip validation if auth is handled externally (proxy/gateway)
        if self.dangerously_skip_auth {
            return Ok(());
        }

        match &self.auth {
            AnthropicAuth::ApiKey(k) if !k.expose_secret().trim().is_empty() => Ok(()),
            AnthropicAuth::Bearer(t) if !t.expose_secret().trim().is_empty() => Ok(()),
            AnthropicAuth::Both { api_key, bearer }
                if !api_key.expose_secret().trim().is_empty()
                    && !bearer.expose_secret().trim().is_empty() =>
            {
                Ok(())
            }
            _ => Err(AnthropicError::Config(
                "Missing Anthropic credentials: set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN"
                    .into(),
            )),
        }
    }

    /// Sets beta features using the `BetaFeature` enum
    ///
    /// This is a type-safe alternative to [`with_beta`](Self::with_beta).
    #[must_use]
    pub fn with_beta_features<I: IntoIterator<Item = BetaFeature>>(mut self, features: I) -> Self {
        self.beta = features.into_iter().map(Into::<String>::into).collect();
        self
    }
}

/// Configuration trait for the Anthropic client
///
/// Implement this trait to provide custom authentication and API configuration.
pub trait Config: Send + Sync {
    /// Returns HTTP headers to include in requests
    ///
    /// # Errors
    ///
    /// Returns an error if header values contain invalid characters.
    fn headers(&self) -> Result<HeaderMap, crate::error::AnthropicError>;

    /// Constructs the full URL for an API endpoint
    fn url(&self, path: &str) -> String;

    /// Returns query parameters to include in requests
    fn query(&self) -> Vec<(&str, &str)>;

    /// Validates that authentication credentials are present.
    ///
    /// # Errors
    ///
    /// Returns an error if authentication is not properly configured.
    fn validate_auth(&self) -> Result<(), crate::error::AnthropicError>;
}

impl Config for AnthropicConfig {
    fn headers(&self) -> Result<HeaderMap, crate::error::AnthropicError> {
        use crate::error::AnthropicError;

        let mut h = HeaderMap::new();

        h.insert(
            HDR_ANTHROPIC_VERSION,
            HeaderValue::from_str(&self.version)
                .map_err(|_| AnthropicError::Config("Invalid anthropic-version header".into()))?,
        );

        if !self.beta.is_empty() {
            let v = self.beta.join(",");
            h.insert(
                HDR_ANTHROPIC_BETA,
                HeaderValue::from_str(&v)
                    .map_err(|_| AnthropicError::Config("Invalid anthropic-beta header".into()))?,
            );
        }

        // Skip auth headers if auth is handled externally (proxy/gateway)
        if !self.dangerously_skip_auth {
            match &self.auth {
                AnthropicAuth::ApiKey(k) => {
                    h.insert(
                        HDR_X_API_KEY,
                        HeaderValue::from_str(k.expose_secret()).map_err(|_| {
                            AnthropicError::Config("Invalid x-api-key value".into())
                        })?,
                    );
                }
                AnthropicAuth::Bearer(t) => {
                    let v = format!("Bearer {}", t.expose_secret());
                    h.insert(
                        AUTHORIZATION,
                        HeaderValue::from_str(&v).map_err(|_| {
                            AnthropicError::Config("Invalid Authorization header".into())
                        })?,
                    );
                }
                AnthropicAuth::Both { api_key, bearer } => {
                    h.insert(
                        HDR_X_API_KEY,
                        HeaderValue::from_str(api_key.expose_secret()).map_err(|_| {
                            AnthropicError::Config("Invalid x-api-key value".into())
                        })?,
                    );
                    let v = format!("Bearer {}", bearer.expose_secret());
                    h.insert(
                        AUTHORIZATION,
                        HeaderValue::from_str(&v).map_err(|_| {
                            AnthropicError::Config("Invalid Authorization header".into())
                        })?,
                    );
                }
                AnthropicAuth::None => {}
            }
        }

        Ok(h)
    }

    fn url(&self, path: &str) -> String {
        format!("{}{}", self.api_base, path)
    }

    fn query(&self) -> Vec<(&str, &str)> {
        vec![]
    }

    fn validate_auth(&self) -> Result<(), crate::error::AnthropicError> {
        self.validate_auth()
    }
}

/// Known Anthropic beta features
///
/// See the [Anthropic API documentation](https://docs.anthropic.com/en/api) for details on each feature.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BetaFeature {
    /// Prompt caching (2024-07-31)
    PromptCaching20240731,
    /// Extended cache TTL (2025-04-11)
    ExtendedCacheTtl20250411,
    /// Token counting (2024-11-01)
    TokenCounting20241101,
    /// Structured outputs (2025-09-17) — Python SDK version
    StructuredOutputs20250917,
    /// Structured outputs (2025-11-13) — TypeScript SDK version (recommended)
    StructuredOutputs20251113,
    /// Alias to the latest structured outputs beta (currently 2025-11-13)
    StructuredOutputsLatest,
    /// Custom beta feature string
    Other(String),
}

impl From<BetaFeature> for String {
    fn from(b: BetaFeature) -> Self {
        match b {
            BetaFeature::PromptCaching20240731 => "prompt-caching-2024-07-31".into(),
            BetaFeature::ExtendedCacheTtl20250411 => "extended-cache-ttl-2025-04-11".into(),
            BetaFeature::TokenCounting20241101 => "token-counting-2024-11-01".into(),
            BetaFeature::StructuredOutputs20250917 => "structured-outputs-2025-09-17".into(),
            BetaFeature::StructuredOutputs20251113 | BetaFeature::StructuredOutputsLatest => {
                "structured-outputs-2025-11-13".into()
            }
            BetaFeature::Other(s) => s,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_headers_exist() {
        let cfg = AnthropicConfig::new();
        let h = cfg.headers().unwrap();
        assert!(h.contains_key(super::HDR_ANTHROPIC_VERSION));
    }

    #[test]
    fn auth_api_key_header() {
        let cfg = AnthropicConfig::new().with_api_key("k123");
        let h = cfg.headers().unwrap();
        assert!(h.contains_key(HDR_X_API_KEY));
        assert!(!h.contains_key(reqwest::header::AUTHORIZATION));
    }

    #[test]
    fn auth_bearer_header() {
        let cfg = AnthropicConfig::new().with_bearer("t123");
        let h = cfg.headers().unwrap();
        assert!(h.contains_key(reqwest::header::AUTHORIZATION));
        assert!(!h.contains_key(HDR_X_API_KEY));
    }

    #[test]
    fn auth_both_headers() {
        let cfg = AnthropicConfig::new().with_both("k123", "t123");
        let h = cfg.headers().unwrap();
        assert!(h.contains_key(HDR_X_API_KEY));
        assert!(h.contains_key(reqwest::header::AUTHORIZATION));
    }

    #[test]
    fn beta_header_join() {
        let cfg = AnthropicConfig::new().with_beta(vec!["a", "b"]);
        let h = cfg.headers().unwrap();
        let v = h.get(HDR_ANTHROPIC_BETA).unwrap().to_str().unwrap();
        assert_eq!(v, "a,b");
    }

    #[test]
    fn invalid_header_values_error() {
        let cfg = AnthropicConfig::new().with_api_key("bad\nkey");
        match cfg.headers() {
            Err(crate::error::AnthropicError::Config(msg)) => assert!(msg.contains("x-api-key")),
            other => panic!("Expected Config error, got {other:?}"),
        }
    }

    #[test]
    fn validate_auth_missing() {
        let cfg = AnthropicConfig {
            api_base: "test".into(),
            version: "test".into(),
            auth: AnthropicAuth::None,
            beta: vec![],
            dangerously_skip_auth: false,
        };
        assert!(cfg.validate_auth().is_err());
    }

    #[test]
    fn debug_output_redacts_api_key() {
        let cfg = AnthropicConfig::new().with_api_key("super-secret-key-12345");
        let debug_str = format!("{cfg:?}");

        assert!(
            !debug_str.contains("super-secret-key-12345"),
            "Debug output should not contain the API key"
        );
        // SecretString uses [REDACTED] format
        assert!(
            debug_str.contains("[REDACTED]"),
            "Debug output should contain '[REDACTED]', got: {debug_str}"
        );
    }

    #[test]
    fn debug_output_redacts_bearer() {
        let cfg = AnthropicConfig::new().with_bearer("super-secret-token-12345");
        let debug_str = format!("{cfg:?}");

        assert!(
            !debug_str.contains("super-secret-token-12345"),
            "Debug output should not contain the bearer token"
        );
        // SecretString uses [REDACTED] format
        assert!(
            debug_str.contains("[REDACTED]"),
            "Debug output should contain '[REDACTED]', got: {debug_str}"
        );
    }

    #[test]
    fn debug_output_redacts_both() {
        let cfg = AnthropicConfig::new().with_both("secret-api-key", "secret-bearer-token");
        let debug_str = format!("{cfg:?}");

        assert!(
            !debug_str.contains("secret-api-key"),
            "Debug output should not contain the API key"
        );
        assert!(
            !debug_str.contains("secret-bearer-token"),
            "Debug output should not contain the bearer token"
        );
        // SecretString uses [REDACTED] format
        assert!(
            debug_str.contains("[REDACTED]"),
            "Debug output should contain '[REDACTED]', got: {debug_str}"
        );
    }

    #[test]
    fn validate_auth_rejects_empty_api_key() {
        let cfg = AnthropicConfig::new().with_api_key("");
        assert!(cfg.validate_auth().is_err());

        let cfg = AnthropicConfig::new().with_api_key("   ");
        assert!(cfg.validate_auth().is_err());

        let cfg = AnthropicConfig::new().with_api_key("\n");
        assert!(cfg.validate_auth().is_err());
    }

    #[test]
    fn validate_auth_rejects_empty_bearer() {
        let cfg = AnthropicConfig::new().with_bearer("");
        assert!(cfg.validate_auth().is_err());

        let cfg = AnthropicConfig::new().with_bearer("   ");
        assert!(cfg.validate_auth().is_err());
    }

    #[test]
    fn validate_auth_rejects_empty_both() {
        // Both empty
        let cfg = AnthropicConfig::new().with_both("", "");
        assert!(cfg.validate_auth().is_err());

        // API key empty, bearer valid
        let cfg = AnthropicConfig::new().with_both("", "valid-token");
        assert!(cfg.validate_auth().is_err());

        // API key valid, bearer empty
        let cfg = AnthropicConfig::new().with_both("valid-key", "");
        assert!(cfg.validate_auth().is_err());

        // Both whitespace
        let cfg = AnthropicConfig::new().with_both("   ", "   ");
        assert!(cfg.validate_auth().is_err());
    }

    #[test]
    fn validate_auth_accepts_valid_credentials() {
        let cfg = AnthropicConfig::new().with_api_key("valid-key");
        assert!(cfg.validate_auth().is_ok());

        let cfg = AnthropicConfig::new().with_bearer("valid-token");
        assert!(cfg.validate_auth().is_ok());

        let cfg = AnthropicConfig::new().with_both("valid-key", "valid-token");
        assert!(cfg.validate_auth().is_ok());

        // Valid with leading/trailing whitespace (trimmed internally)
        let cfg = AnthropicConfig::new().with_api_key("  valid-key  ");
        assert!(cfg.validate_auth().is_ok());
    }

    #[test]
    fn dangerously_skip_auth_bypasses_validation() {
        // Without skip_auth, no credentials fails validation
        let cfg_normal = AnthropicConfig {
            api_base: "test".into(),
            version: "test".into(),
            auth: AnthropicAuth::None,
            beta: vec![],
            dangerously_skip_auth: false,
        };
        assert!(cfg_normal.validate_auth().is_err());

        // With skip_auth, validation succeeds despite no credentials
        let cfg_skip = AnthropicConfig::new().dangerously_skip_auth();
        assert!(
            cfg_skip.validate_auth().is_ok(),
            "validate_auth must succeed when dangerously_skip_auth is set"
        );
    }

    #[test]
    fn dangerously_skip_auth_omits_headers() {
        // Start with valid credentials, then skip auth ("skip wins")
        let cfg = AnthropicConfig::new()
            .with_both("test-key", "test-token")
            .dangerously_skip_auth();

        let headers = cfg.headers().unwrap();

        // Should NOT contain auth headers
        assert!(
            !headers.contains_key(HDR_X_API_KEY),
            "x-api-key must not be present when dangerously_skip_auth is set"
        );
        assert!(
            !headers.contains_key(reqwest::header::AUTHORIZATION),
            "Authorization must not be present when dangerously_skip_auth is set"
        );

        // Should still contain non-auth headers
        assert!(headers.contains_key(HDR_ANTHROPIC_VERSION));
    }
}