use std::collections::HashMap;
use std::env;
use crate::core::net::{ProviderEndpointAccess, ProviderEndpointPolicy};
use crate::core::providers::unified_provider::ProviderError;
use crate::core::traits::provider::ProviderConfig;
#[derive(Debug, Clone)]
pub struct AnthropicConfig {
pub api_key: Option<String>,
pub base_url: String,
pub endpoint_access: ProviderEndpointAccess,
pub api_version: String,
pub request_timeout: u64,
pub connect_timeout: u64,
pub max_retries: u32,
pub retry_delay_base: u64,
pub proxy_url: Option<String>,
pub custom_headers: HashMap<String, String>,
pub enable_multimodal: bool,
pub enable_cache_control: bool,
pub enable_computer_use: bool,
pub enable_experimental: bool,
pub allow_unknown_models: bool,
pub configured_models: Vec<String>,
pub configured_multimodal_models: Vec<String>,
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
api_key: None,
base_url: "https://api.anthropic.com".to_string(),
endpoint_access: ProviderEndpointAccess::PublicOnly,
api_version: "2023-06-01".to_string(),
request_timeout: 120,
connect_timeout: 10,
max_retries: 3,
retry_delay_base: 1000,
proxy_url: None,
custom_headers: HashMap::new(),
enable_multimodal: true,
enable_cache_control: true,
enable_computer_use: false, enable_experimental: false,
allow_unknown_models: false,
configured_models: Vec::new(),
configured_multimodal_models: Vec::new(),
}
}
}
impl AnthropicConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: Some(api_key.into()),
..Default::default()
}
}
pub fn new_test(api_key: impl Into<String>) -> Self {
Self {
api_key: Some(api_key.into()),
base_url: "https://api.anthropic.com".to_string(),
..Default::default()
}
}
pub fn from_env() -> Result<Self, ProviderError> {
let api_key = env::var("ANTHROPIC_API_KEY")
.or_else(|_| env::var("CLAUDE_API_KEY"))
.map(Some)
.map_err(|_| {
ProviderError::configuration(
"anthropic",
"ANTHROPIC_API_KEY or CLAUDE_API_KEY environment variable is required",
)
})?;
let mut config = Self {
api_key,
..Default::default()
};
if let Ok(base_url) = env::var("ANTHROPIC_BASE_URL") {
config.base_url = base_url;
}
if let Ok(api_version) = env::var("ANTHROPIC_API_VERSION") {
config.api_version = api_version;
}
if let Ok(timeout) = env::var("ANTHROPIC_TIMEOUT") {
config.request_timeout = timeout.parse().unwrap_or(120);
}
if let Ok(proxy) = env::var("ANTHROPIC_PROXY") {
config.proxy_url = Some(proxy);
}
if let Ok(multimodal) = env::var("ANTHROPIC_ENABLE_MULTIMODAL") {
config.enable_multimodal = multimodal.parse().unwrap_or(true);
}
if let Ok(cache) = env::var("ANTHROPIC_ENABLE_CACHE") {
config.enable_cache_control = cache.parse().unwrap_or(true);
}
if let Ok(computer) = env::var("ANTHROPIC_ENABLE_COMPUTER_USE") {
config.enable_computer_use = computer.parse().unwrap_or(false);
}
if let Ok(experimental) = env::var("ANTHROPIC_ENABLE_EXPERIMENTAL") {
config.enable_experimental = experimental.parse().unwrap_or(false);
}
if let Ok(allow_unknown_models) = env::var("ANTHROPIC_ALLOW_UNKNOWN_MODELS") {
config.allow_unknown_models = allow_unknown_models.parse().unwrap_or(false);
}
if let Some(models) = env_model_list("ANTHROPIC_MODELS")
.or_else(|| env_model_list("ANTHROPIC_CONFIGURED_MODELS"))
{
config.configured_models = models;
}
if let Some(models) = env_model_list("ANTHROPIC_MULTIMODAL_MODELS")
.or_else(|| env_model_list("ANTHROPIC_CONFIGURED_MULTIMODAL_MODELS"))
{
config.configured_multimodal_models = models;
}
config
.validate()
.map_err(|e| ProviderError::configuration("anthropic", e))?;
Ok(config)
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn with_endpoint_access(mut self, access: ProviderEndpointAccess) -> Self {
self.endpoint_access = access;
self
}
pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = version.into();
self
}
pub fn with_timeout(mut self, timeout: u64) -> Self {
self.request_timeout = timeout;
self
}
pub fn with_proxy(mut self, proxy_url: impl Into<String>) -> Self {
self.proxy_url = Some(proxy_url.into());
self
}
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom_headers.insert(key.into(), value.into());
self
}
pub fn with_multimodal(mut self, enabled: bool) -> Self {
self.enable_multimodal = enabled;
self
}
pub fn with_cache_control(mut self, enabled: bool) -> Self {
self.enable_cache_control = enabled;
self
}
pub fn with_computer_use(mut self, enabled: bool) -> Self {
self.enable_computer_use = enabled;
self
}
pub fn with_experimental(mut self, enabled: bool) -> Self {
self.enable_experimental = enabled;
self
}
pub fn with_allow_unknown_models(mut self, enabled: bool) -> Self {
self.allow_unknown_models = enabled;
self
}
pub fn with_configured_models(mut self, models: Vec<String>) -> Self {
self.configured_models = models;
self
}
pub fn with_configured_multimodal_models(mut self, models: Vec<String>) -> Self {
self.configured_multimodal_models = models;
self
}
pub fn allows_unknown_model(&self, model: &str) -> bool {
self.allow_unknown_models
&& self.base_url.trim_end_matches('/') != "https://api.anthropic.com"
&& self
.configured_models
.iter()
.any(|configured| configured == model)
}
pub fn uses_compatible_model_allow_list(&self) -> bool {
self.allow_unknown_models
&& self.base_url.trim_end_matches('/') != "https://api.anthropic.com"
}
pub fn allows_unknown_model_image_input(&self, model: &str) -> bool {
self.enable_multimodal
&& self.allows_unknown_model(model)
&& self
.configured_multimodal_models
.iter()
.any(|configured| configured == model)
}
pub fn has_unknown_multimodal_model_outside_allow_list(&self) -> bool {
self.configured_multimodal_models.iter().any(|model| {
!self
.configured_models
.iter()
.any(|configured| configured == model)
})
}
pub fn get_api_url(&self, endpoint: &str) -> String {
format!("{}{}", self.base_url.trim_end_matches('/'), endpoint)
}
pub fn is_feature_enabled(&self, feature: &str) -> bool {
match feature {
"multimodal" => self.enable_multimodal,
"cache_control" => self.enable_cache_control,
"computer_use" => self.enable_computer_use,
"experimental" => self.enable_experimental,
_ => false,
}
}
pub(crate) fn validate_policy_client_settings(&self) -> Result<(), String> {
if self.proxy_url.is_some() {
return Err(
"Anthropic proxy configuration is incompatible with endpoint policy enforcement"
.to_string(),
);
}
if self.connect_timeout != 10 {
return Err(
"Anthropic connect_timeout must remain 10 seconds with the policy-aware client"
.to_string(),
);
}
ProviderEndpointPolicy::for_base_url(self.endpoint_access, &self.base_url)
.map(|_| ())
.map_err(|error| format!("invalid Anthropic base URL policy: {error}"))
}
}
impl ProviderConfig for AnthropicConfig {
fn validate(&self) -> Result<(), String> {
let api_key = self.api_key.as_ref().ok_or("API key is required")?;
if api_key.is_empty() {
return Err("API key cannot be empty".to_string());
}
let first_party_anthropic =
self.base_url.trim_end_matches('/') == "https://api.anthropic.com";
let custom_anthropic_base = !first_party_anthropic;
let compatible_unknown_models = self.allow_unknown_models && custom_anthropic_base;
if self.allow_unknown_models && first_party_anthropic {
return Err(
"allow_unknown_models requires a non-Anthropic compatible base URL".to_string(),
);
}
if compatible_unknown_models && self.configured_models.is_empty() {
return Err("allow_unknown_models requires an explicit models allow-list".to_string());
}
if compatible_unknown_models && self.has_unknown_multimodal_model_outside_allow_list() {
return Err(
"multimodal_models must be included in the explicit models allow-list".to_string(),
);
}
if !custom_anthropic_base && !api_key.starts_with("sk-ant-") {
return Err(
"Invalid Anthropic API key format. Keys should start with 'sk-ant-'".to_string(),
);
}
let minimum_key_len = if custom_anthropic_base { 8 } else { 20 };
if api_key.len() < minimum_key_len {
return Err("API key appears to be too short".to_string());
}
if self.base_url.is_empty() {
return Err("Base URL cannot be empty".to_string());
}
if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") {
return Err("Base URL must start with http:// or https://".to_string());
}
if self.request_timeout == 0 {
return Err("Request timeout must be greater than 0".to_string());
}
if self.connect_timeout == 0 {
return Err("Connect timeout must be greater than 0".to_string());
}
if self.connect_timeout > self.request_timeout {
return Err("Connect timeout cannot be greater than request timeout".to_string());
}
self.validate_policy_client_settings()?;
Ok(())
}
fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
fn api_base(&self) -> Option<&str> {
Some(&self.base_url)
}
fn endpoint_access(&self) -> ProviderEndpointAccess {
self.endpoint_access
}
fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.request_timeout)
}
fn max_retries(&self) -> u32 {
self.max_retries
}
}
fn env_model_list(name: &str) -> Option<Vec<String>> {
env::var(name).ok().map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|model| !model.is_empty())
.map(str::to_string)
.collect()
})
}
pub struct AnthropicConfigBuilder {
config: AnthropicConfig,
}
impl AnthropicConfigBuilder {
pub fn new() -> Self {
Self {
config: AnthropicConfig::default(),
}
}
pub fn from_env() -> Result<Self, ProviderError> {
Ok(Self {
config: AnthropicConfig::from_env()?,
})
}
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.config.api_key = Some(api_key.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.config.base_url = base_url.into();
self
}
pub fn timeout(mut self, timeout: u64) -> Self {
self.config.request_timeout = timeout;
self
}
pub fn multimodal(mut self, enabled: bool) -> Self {
self.config.enable_multimodal = enabled;
self
}
pub fn experimental(mut self, enabled: bool) -> Self {
self.config.enable_experimental = enabled;
self.config.enable_computer_use = enabled; self
}
pub fn allow_unknown_models(mut self, enabled: bool) -> Self {
self.config.allow_unknown_models = enabled;
self
}
pub fn configured_models(mut self, models: Vec<String>) -> Self {
self.config.configured_models = models;
self
}
pub fn configured_multimodal_models(mut self, models: Vec<String>) -> Self {
self.config.configured_multimodal_models = models;
self
}
pub fn build(self) -> Result<AnthropicConfig, ProviderError> {
self.config
.validate()
.map_err(|e| ProviderError::configuration("anthropic", e))?;
Ok(self.config)
}
}
impl Default for AnthropicConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ANTHROPIC_ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_default_config() {
let config = AnthropicConfig::default();
assert_eq!(config.base_url, "https://api.anthropic.com");
assert_eq!(config.endpoint_access, ProviderEndpointAccess::PublicOnly);
assert_eq!(config.api_version, "2023-06-01");
assert!(config.enable_multimodal);
assert!(config.enable_cache_control);
assert!(!config.enable_computer_use);
}
#[test]
fn test_config_validation() {
let mut config = AnthropicConfig::default();
assert!(config.validate().is_err());
config.api_key = Some("sk-ant-api03-test123456".to_string());
assert!(config.validate().is_ok());
config.api_key = Some("invalid-key".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_policy_client_settings_fail_closed() {
let mut config = AnthropicConfig::new_test("test-key");
config.base_url = "http://127.0.0.1:18080".to_string();
assert!(crate::core::providers::anthropic::AnthropicClient::new(config.clone()).is_err());
config.endpoint_access = ProviderEndpointAccess::PrivateNetwork;
assert!(crate::core::providers::anthropic::AnthropicClient::new(config.clone()).is_ok());
config.proxy_url = Some("http://proxy.example".to_string());
let error = crate::core::providers::anthropic::AnthropicClient::new(config.clone())
.expect_err("proxy must fail closed");
assert!(error.to_string().contains("proxy"));
config.proxy_url = None;
config.connect_timeout = 9;
let error = crate::core::providers::anthropic::AnthropicClient::new(config)
.expect_err("custom connect timeout must fail closed");
assert!(error.to_string().contains("connect_timeout"));
}
#[test]
fn test_config_builder() {
let config = AnthropicConfigBuilder::new()
.api_key("sk-ant-test1234567890123")
.base_url("https://custom.api.com")
.timeout(60)
.multimodal(false)
.build();
let config = match config {
Ok(config) => config,
Err(err) => panic!("compatible upstream config should build: {err}"),
};
assert_eq!(config.api_key, Some("sk-ant-test1234567890123".to_string()));
assert_eq!(config.base_url, "https://custom.api.com");
assert_eq!(config.request_timeout, 60);
assert!(!config.enable_multimodal);
}
#[test]
fn test_config_builder_allows_compatible_upstream_keys() {
let config = AnthropicConfigBuilder::new()
.api_key("xiaomi-compatible-key")
.base_url("https://token-plan-sgp.xiaomimimo.com/anthropic")
.allow_unknown_models(true)
.configured_models(vec!["mimo-v2.5".to_string()])
.build();
let config = match config {
Ok(config) => config,
Err(err) => panic!("compatible upstream key should validate: {err}"),
};
assert_eq!(config.api_key.as_deref(), Some("xiaomi-compatible-key"));
assert!(config.allows_unknown_model("mimo-v2.5"));
assert!(!config.allows_unknown_model("unlisted-model"));
}
#[test]
fn test_config_builder_allows_gateway_keys_on_custom_base_without_unknown_models() {
let config = AnthropicConfigBuilder::new()
.api_key("gateway-compatible-key")
.base_url("https://gateway.example.com/anthropic")
.build();
let config = match config {
Ok(config) => config,
Err(err) => {
panic!("custom Anthropic-compatible base should accept gateway keys: {err}")
}
};
assert_eq!(config.api_key.as_deref(), Some("gateway-compatible-key"));
assert!(!config.allow_unknown_models);
}
#[test]
fn test_config_builder_requires_compatible_model_allow_list() {
let config = AnthropicConfigBuilder::new()
.api_key("xiaomi-compatible-key")
.base_url("https://token-plan-sgp.xiaomimimo.com/anthropic")
.allow_unknown_models(true)
.build();
let Err(err) = config else {
panic!("compatible upstreams should require explicit model IDs");
};
assert!(format!("{err}").contains("explicit models allow-list"));
}
#[test]
fn test_config_builder_rejects_multimodal_model_outside_allow_list() {
let config = AnthropicConfigBuilder::new()
.api_key("xiaomi-compatible-key")
.base_url("https://token-plan-sgp.xiaomimimo.com/anthropic")
.allow_unknown_models(true)
.configured_models(vec!["mimo-v2.5-pro".to_string()])
.configured_multimodal_models(vec!["mimo-v2.5".to_string()])
.build();
let Err(err) = config else {
panic!("compatible multimodal models should stay inside the model allow-list");
};
assert!(format!("{err}").contains("multimodal_models"));
}
#[test]
fn test_config_builder_rejects_first_party_unknown_model_opt_in() {
let config = AnthropicConfigBuilder::new()
.api_key("sk-ant-test1234567890123")
.allow_unknown_models(true)
.configured_models(vec!["mimo-v2.5".to_string()])
.build();
let Err(err) = config else {
panic!("first-party Anthropic should not accept unknown-model opt-in");
};
assert!(format!("{err}").contains("non-Anthropic compatible base URL"));
}
#[test]
fn from_env_reads_compatible_model_allow_lists() {
let _env_lock = ANTHROPIC_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
const KEYS: &[&str] = &[
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_ALLOW_UNKNOWN_MODELS",
"ANTHROPIC_MODELS",
"ANTHROPIC_MULTIMODAL_MODELS",
];
let previous: Vec<_> = KEYS
.iter()
.map(|key| (*key, std::env::var(key).ok()))
.collect();
for key in KEYS {
unsafe { std::env::remove_var(key) };
}
unsafe {
std::env::set_var("ANTHROPIC_API_KEY", "xiaomi-compatible-key");
std::env::set_var(
"ANTHROPIC_BASE_URL",
"https://token-plan-sgp.xiaomimimo.com/anthropic",
);
std::env::set_var("ANTHROPIC_ALLOW_UNKNOWN_MODELS", "true");
std::env::set_var("ANTHROPIC_MODELS", "mimo-v2.5, mimo-v2.5-pro");
std::env::set_var("ANTHROPIC_MULTIMODAL_MODELS", "mimo-v2.5");
}
let config = AnthropicConfig::from_env()
.unwrap_or_else(|err| panic!("env config should parse: {err}"));
assert!(config.allow_unknown_models);
assert_eq!(
config.configured_models,
vec!["mimo-v2.5".to_string(), "mimo-v2.5-pro".to_string()]
);
assert_eq!(
config.configured_multimodal_models,
vec!["mimo-v2.5".to_string()]
);
assert!(config.validate().is_ok());
for (key, value) in previous {
match value {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
}
}
}
#[test]
fn from_env_rejects_compatible_opt_in_without_model_allow_list() {
let _env_lock = ANTHROPIC_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
const KEYS: &[&str] = &[
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_ALLOW_UNKNOWN_MODELS",
"ANTHROPIC_MODELS",
"ANTHROPIC_CONFIGURED_MODELS",
"ANTHROPIC_MULTIMODAL_MODELS",
"ANTHROPIC_CONFIGURED_MULTIMODAL_MODELS",
];
let previous: Vec<(&str, Option<String>)> = KEYS
.iter()
.map(|key| (*key, std::env::var(key).ok()))
.collect();
for key in KEYS {
unsafe { std::env::remove_var(key) };
}
unsafe {
std::env::set_var("ANTHROPIC_API_KEY", "xiaomi-compatible-key");
std::env::set_var(
"ANTHROPIC_BASE_URL",
"https://token-plan-sgp.xiaomimimo.com/anthropic",
);
std::env::set_var("ANTHROPIC_ALLOW_UNKNOWN_MODELS", "true");
}
let err = match AnthropicConfig::from_env() {
Ok(_) => panic!("compatible env opt-in must require explicit model IDs"),
Err(err) => err,
};
assert!(format!("{err}").contains("explicit models allow-list"));
for (key, value) in previous {
match value {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
}
}
}
#[test]
fn test_feature_check() {
let config = AnthropicConfig::default();
assert!(config.is_feature_enabled("multimodal"));
assert!(config.is_feature_enabled("cache_control"));
assert!(!config.is_feature_enabled("computer_use"));
assert!(!config.is_feature_enabled("unknown_feature"));
}
#[test]
fn test_api_url_generation() {
let config = AnthropicConfig::default();
assert_eq!(
config.get_api_url("/v1/messages"),
"https://api.anthropic.com/v1/messages"
);
let config = config.with_base_url("https://api.anthropic.com/");
assert_eq!(
config.get_api_url("/v1/messages"),
"https://api.anthropic.com/v1/messages"
);
}
}