use reqwest::header::AUTHORIZATION;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
pub const ANTHROPIC_DEFAULT_BASE: &str = "https://api.anthropic.com";
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
pub const HDR_ANTHROPIC_VERSION: &str = "anthropic-version";
pub const HDR_ANTHROPIC_BETA: &str = "anthropic-beta";
pub const HDR_X_API_KEY: &str = "x-api-key";
#[derive(Clone, Debug)]
pub enum AnthropicAuth {
ApiKey(SecretString),
Bearer(SecretString),
Both {
api_key: SecretString,
bearer: SecretString,
},
None,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct AnthropicConfig {
api_base: String,
version: String,
#[serde(skip)]
auth: AnthropicAuth,
#[serde(skip)]
beta: Vec<String>,
#[serde(skip)]
dangerously_skip_auth: bool,
}
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 {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
self.api_base = base.into();
self
}
#[must_use]
pub fn with_version(mut self, v: impl Into<String>) -> Self {
self.version = v.into();
self
}
#[must_use]
pub fn with_api_key(mut self, k: impl Into<String>) -> Self {
self.auth = AnthropicAuth::ApiKey(SecretString::from(k.into()));
self
}
#[must_use]
pub fn with_bearer(mut self, t: impl Into<String>) -> Self {
self.auth = AnthropicAuth::Bearer(SecretString::from(t.into()));
self
}
#[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
}
#[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
}
#[must_use]
pub fn dangerously_skip_auth(mut self) -> Self {
self.dangerously_skip_auth = true;
self.auth = AnthropicAuth::None;
self
}
#[must_use]
pub fn api_base(&self) -> &str {
&self.api_base
}
pub fn validate_auth(&self) -> Result<(), crate::error::AnthropicError> {
use crate::error::AnthropicError;
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(),
)),
}
}
#[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
}
}
pub trait Config: Send + Sync {
fn headers(&self) -> Result<HeaderMap, crate::error::AnthropicError>;
fn url(&self, path: &str) -> String;
fn query(&self) -> Vec<(&str, &str)>;
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()))?,
);
}
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()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BetaFeature {
PromptCaching20240731,
ExtendedCacheTtl20250411,
TokenCounting20241101,
StructuredOutputs20250917,
StructuredOutputs20251113,
StructuredOutputsLatest,
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"
);
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"
);
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"
);
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() {
let cfg = AnthropicConfig::new().with_both("", "");
assert!(cfg.validate_auth().is_err());
let cfg = AnthropicConfig::new().with_both("", "valid-token");
assert!(cfg.validate_auth().is_err());
let cfg = AnthropicConfig::new().with_both("valid-key", "");
assert!(cfg.validate_auth().is_err());
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());
let cfg = AnthropicConfig::new().with_api_key(" valid-key ");
assert!(cfg.validate_auth().is_ok());
}
#[test]
fn dangerously_skip_auth_bypasses_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());
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() {
let cfg = AnthropicConfig::new()
.with_both("test-key", "test-token")
.dangerously_skip_auth();
let headers = cfg.headers().unwrap();
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"
);
assert!(headers.contains_key(HDR_ANTHROPIC_VERSION));
}
}