use super::types::CacheControl;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicConfig {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "AnthropicConfig::default_base_url")]
pub base_url: String,
#[serde(default = "AnthropicConfig::default_model")]
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pricing_model: Option<String>,
#[serde(default = "AnthropicConfig::default_max_tokens")]
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(default = "AnthropicConfig::default_stream")]
pub stream: bool,
#[serde(default)]
pub stop_sequences: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caching: Option<CachingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoiceConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<EffortLevel>,
#[serde(default)]
pub beta_features: BetaFeatures,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<RequestMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_retry: Option<NetworkRetryConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate_limiter: Option<RateLimiterConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bedrock: Option<BedrockConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub azure: Option<AzureAnthropicConfig>,
}
impl AnthropicConfig {
fn default_base_url() -> String {
"https://api.anthropic.com".to_string()
}
fn default_model() -> String {
"claude-sonnet-4-5".to_string()
}
fn default_max_tokens() -> u32 {
4096
}
fn default_stream() -> bool {
true
}
pub fn validate(&self) -> Result<()> {
if self.bedrock.is_some() && self.azure.is_some() {
return Err(anyhow!(
"AnthropicConfig cannot enable both bedrock and azure transports at the same time"
));
}
if let Some(ref azure) = self.azure {
azure.normalized_base_url()?;
}
if let Some(ref thinking) = self.thinking {
if thinking.enabled {
if thinking.budget_tokens >= self.max_tokens {
return Err(anyhow!(
"thinking.budget_tokens ({}) must be < max_tokens ({})",
thinking.budget_tokens,
self.max_tokens
));
}
if thinking.budget_tokens < 1024 {
return Err(anyhow!(
"thinking.budget_tokens ({}) must be >= 1024",
thinking.budget_tokens
));
}
if self.temperature.is_some() {
return Err(anyhow!(
"Extended thinking is incompatible with temperature parameter"
));
}
if self.top_k.is_some() {
return Err(anyhow!(
"Extended thinking is incompatible with top_k parameter"
));
}
}
}
if self.beta_features.context_1m
&& !self.model.contains("sonnet-4")
&& !self.model.contains("sonnet-4-5")
{
warn!(
"1M context window (context-1m-2025-08-07 beta) is only supported for Claude Sonnet 4 and 4.5. \
Current model: {}. This may fail.",
self.model
);
}
Ok(())
}
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
api_key: None,
base_url: Self::default_base_url(),
model: Self::default_model(),
pricing_model: None,
max_tokens: Self::default_max_tokens(),
temperature: None,
top_p: None,
top_k: None,
stream: Self::default_stream(),
stop_sequences: Vec::new(),
thinking: None,
caching: None,
tool_choice: None,
effort: None,
beta_features: BetaFeatures::default(),
metadata: None,
retry: Some(RetryConfig::default()),
network_retry: Some(NetworkRetryConfig::default()),
rate_limiter: None,
bedrock: None,
azure: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AzureAnthropicAuthMethod {
#[default]
XApiKey,
BearerToken,
}
impl AzureAnthropicAuthMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::XApiKey => "x-api-key",
Self::BearerToken => "bearer",
}
}
}
impl std::str::FromStr for AzureAnthropicAuthMethod {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.trim().to_lowercase().as_str() {
"x_api_key" | "x-api-key" | "api_key" | "apikey" => Ok(Self::XApiKey),
"bearer" | "bearer_token" | "bearer-token" => Ok(Self::BearerToken),
_ => Err(anyhow!(
"Invalid Azure Anthropic auth method: {}. Must be 'x_api_key' or 'bearer'",
s
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureAnthropicConfig {
pub base_url: String,
#[serde(default)]
pub auth_method: AzureAnthropicAuthMethod,
}
impl AzureAnthropicConfig {
pub fn base_url_from_resource(resource: &str) -> Result<String> {
let resource = resource.trim();
if resource.is_empty() {
return Err(anyhow!(
"AZURE_ANTHROPIC_RESOURCE must not be empty when deriving the base URL"
));
}
Ok(format!(
"https://{}.services.ai.azure.com/anthropic",
resource
))
}
pub fn normalized_base_url(&self) -> Result<String> {
let trimmed = self.base_url.trim();
if trimmed.is_empty() {
return Err(anyhow!(
"Azure Anthropic base_url must not be empty when azure transport is enabled"
));
}
let mut parsed = url::Url::parse(trimmed).map_err(|err| {
anyhow!(
"Invalid Azure Anthropic base_url '{}': {}",
self.base_url,
err
)
})?;
parsed.set_query(None);
parsed.set_fragment(None);
let path = parsed.path().trim_end_matches('/');
let normalized_path = if let Some(stripped) = path.strip_suffix("/v1/messages") {
stripped
} else if let Some(stripped) = path.strip_suffix("/v1") {
stripped
} else {
path
};
let final_path = if normalized_path.is_empty() {
"/".to_string()
} else {
normalized_path.to_string()
};
parsed.set_path(&final_path);
let mut normalized = parsed.to_string();
if normalized.ends_with('/') {
normalized.pop();
}
Ok(normalized)
}
}
impl Default for AzureAnthropicConfig {
fn default() -> Self {
Self {
base_url: "https://example-resource.services.ai.azure.com/anthropic".to_string(),
auth_method: AzureAnthropicAuthMethod::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThinkingConfig {
pub enabled: bool,
pub budget_tokens: u32,
#[serde(default)]
pub adaptive: bool,
}
impl ThinkingConfig {
pub fn adaptive() -> Self {
Self {
enabled: false,
budget_tokens: 0,
adaptive: true,
}
}
pub fn enabled(budget_tokens: u32) -> Self {
Self {
enabled: true,
budget_tokens,
adaptive: false,
}
}
pub fn disabled() -> Self {
Self {
enabled: false,
budget_tokens: 0,
adaptive: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachingConfig {
pub enabled: bool,
pub ttl: CacheTTL,
}
impl CachingConfig {
pub fn top_level_cache_control(&self) -> Option<CacheControl> {
if !self.enabled {
return None;
}
Some(CacheControl {
cache_type: "ephemeral".to_string(),
ttl: Some(self.ttl.as_str().to_string()),
})
}
}
impl Default for CachingConfig {
fn default() -> Self {
Self {
enabled: true,
ttl: CacheTTL::FiveMinutes,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CacheTTL {
#[serde(rename = "5m")]
#[default]
FiveMinutes,
#[serde(rename = "1h")]
OneHour,
}
impl CacheTTL {
pub fn as_str(&self) -> &str {
match self {
Self::FiveMinutes => "5m",
Self::OneHour => "1h",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EffortLevel {
Low,
Medium,
#[default]
High,
Max,
}
impl EffortLevel {
pub fn as_str(&self) -> &str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ToolChoiceConfig {
Auto {
#[serde(default)]
disable_parallel_tool_use: bool,
},
Any {
#[serde(default)]
disable_parallel_tool_use: bool,
},
Tool {
name: String,
#[serde(default)]
disable_parallel_tool_use: bool,
},
None,
}
impl Default for ToolChoiceConfig {
fn default() -> Self {
Self::Auto {
disable_parallel_tool_use: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BetaFeatures {
#[serde(default)]
pub fine_grained_tool_streaming: bool,
#[serde(default)]
pub interleaved_thinking: bool,
#[serde(default)]
pub context_management: bool,
#[serde(default)]
pub context_1m: bool,
#[serde(default)]
pub effort: bool,
}
impl BetaFeatures {
pub fn to_header_values(&self) -> Vec<String> {
let mut values = Vec::new();
if self.fine_grained_tool_streaming {
values.push("fine-grained-tool-streaming-2025-05-14".to_string());
}
if self.interleaved_thinking {
values.push("interleaved-thinking-2025-05-14".to_string());
}
if self.context_management {
values.push("context-management-2025-06-27".to_string());
}
if self.context_1m {
values.push("context-1m-2025-08-07".to_string());
}
if self.effort {
values.push("effort-2025-11-24".to_string());
}
values
}
pub fn has_any(&self) -> bool {
self.fine_grained_tool_streaming
|| self.interleaved_thinking
|| self.context_management
|| self.context_1m
|| self.effort
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BedrockAuthMethod {
#[default]
SigV4,
BearerToken,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BedrockConfig {
#[serde(default = "BedrockConfig::default_region")]
pub region: String,
#[serde(default = "BedrockConfig::default_model_id")]
pub model_id: String,
#[serde(default = "BedrockConfig::default_anthropic_version")]
pub anthropic_version: String,
#[serde(default)]
pub auth_method: BedrockAuthMethod,
}
impl BedrockConfig {
fn default_region() -> String {
std::env::var("AWS_REGION")
.or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string())
}
fn default_model_id() -> String {
"us.anthropic.claude-sonnet-4-5-20250514-v1:0".to_string()
}
fn default_anthropic_version() -> String {
"bedrock-2023-05-31".to_string()
}
pub fn streaming_endpoint(&self) -> String {
format!(
"https://bedrock-runtime.{}.amazonaws.com/model/{}/invoke-with-response-stream",
self.region,
self.model_id.replace(':', "%3A")
)
}
pub fn invoke_endpoint(&self) -> String {
format!(
"https://bedrock-runtime.{}.amazonaws.com/model/{}/invoke",
self.region,
self.model_id.replace(':', "%3A")
)
}
}
impl Default for BedrockConfig {
fn default() -> Self {
Self {
region: Self::default_region(),
model_id: Self::default_model_id(),
anthropic_version: Self::default_anthropic_version(),
auth_method: BedrockAuthMethod::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimiterConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "RateLimiterConfig::default_tokens_per_minute")]
pub tokens_per_minute: u32,
}
impl RateLimiterConfig {
fn default_tokens_per_minute() -> u32 {
1_800_000 }
}
impl Default for RateLimiterConfig {
fn default() -> Self {
Self {
enabled: false,
tokens_per_minute: Self::default_tokens_per_minute(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
#[serde(default = "RetryConfig::default_max_retries")]
pub max_retries: u32,
#[serde(default = "RetryConfig::default_initial_backoff_ms")]
pub initial_backoff_ms: u64,
#[serde(default = "RetryConfig::default_max_backoff_ms")]
pub max_backoff_ms: u64,
#[serde(default = "RetryConfig::default_backoff_multiplier")]
pub backoff_multiplier: f32,
#[serde(default = "RetryConfig::default_jitter")]
pub jitter: bool,
}
impl RetryConfig {
fn default_max_retries() -> u32 {
5
}
fn default_initial_backoff_ms() -> u64 {
1000
}
fn default_max_backoff_ms() -> u64 {
60000
}
fn default_backoff_multiplier() -> f32 {
2.0
}
fn default_jitter() -> bool {
true
}
pub fn calculate_backoff(&self, attempt: u32) -> u64 {
let exponent = (attempt as f32 - 1.0).max(0.0);
let base_backoff =
(self.initial_backoff_ms as f32) * self.backoff_multiplier.powf(exponent);
let capped_backoff = base_backoff.min(self.max_backoff_ms as f32);
if self.jitter {
let jitter_factor = 1.0 + rand::random_range(-0.5..0.5); (capped_backoff * jitter_factor) as u64
} else {
capped_backoff as u64
}
}
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: Self::default_max_retries(),
initial_backoff_ms: Self::default_initial_backoff_ms(),
max_backoff_ms: Self::default_max_backoff_ms(),
backoff_multiplier: Self::default_backoff_multiplier(),
jitter: Self::default_jitter(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkRetryConfig {
#[serde(default = "NetworkRetryConfig::default_max_retries")]
pub max_retries: u32,
#[serde(default = "NetworkRetryConfig::default_initial_backoff_ms")]
pub initial_backoff_ms: u64,
#[serde(default = "NetworkRetryConfig::default_max_backoff_ms")]
pub max_backoff_ms: u64,
#[serde(default = "NetworkRetryConfig::default_backoff_multiplier")]
pub backoff_multiplier: f32,
#[serde(default = "NetworkRetryConfig::default_jitter")]
pub jitter: bool,
}
impl NetworkRetryConfig {
fn default_max_retries() -> u32 {
3
}
fn default_initial_backoff_ms() -> u64 {
2000
}
fn default_max_backoff_ms() -> u64 {
30000
}
fn default_backoff_multiplier() -> f32 {
2.0
}
fn default_jitter() -> bool {
true
}
pub fn calculate_backoff(&self, attempt: u32) -> u64 {
let exponent = (attempt as f32 - 1.0).max(0.0);
let base_backoff =
(self.initial_backoff_ms as f32) * self.backoff_multiplier.powf(exponent);
let capped_backoff = base_backoff.min(self.max_backoff_ms as f32);
if self.jitter {
let jitter_factor = 1.0 + rand::random_range(-0.5..0.5);
(capped_backoff * jitter_factor) as u64
} else {
capped_backoff as u64
}
}
}
impl Default for NetworkRetryConfig {
fn default() -> Self {
Self {
max_retries: Self::default_max_retries(),
initial_backoff_ms: Self::default_initial_backoff_ms(),
max_backoff_ms: Self::default_max_backoff_ms(),
backoff_multiplier: Self::default_backoff_multiplier(),
jitter: Self::default_jitter(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = AnthropicConfig::default();
assert_eq!(config.model, "claude-sonnet-4-5");
assert_eq!(config.max_tokens, 4096);
assert_eq!(config.base_url, "https://api.anthropic.com");
assert!(config.stream);
assert!(config.azure.is_none());
}
#[test]
fn test_thinking_validation_budget_too_high() {
let config = AnthropicConfig {
max_tokens: 5000,
thinking: Some(ThinkingConfig {
enabled: true,
budget_tokens: 6000, adaptive: false,
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_thinking_validation_budget_too_low() {
let config = AnthropicConfig {
thinking: Some(ThinkingConfig {
enabled: true,
budget_tokens: 512, adaptive: false,
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_thinking_incompatible_with_temperature() {
let config = AnthropicConfig {
thinking: Some(ThinkingConfig {
enabled: true,
budget_tokens: 2000,
adaptive: false,
}),
temperature: Some(0.5),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_beta_features_headers() {
let beta = BetaFeatures {
fine_grained_tool_streaming: true,
interleaved_thinking: true,
..Default::default()
};
let headers = beta.to_header_values();
assert_eq!(headers.len(), 2);
assert!(headers.contains(&"fine-grained-tool-streaming-2025-05-14".to_string()));
assert!(headers.contains(&"interleaved-thinking-2025-05-14".to_string()));
}
#[test]
fn test_cache_ttl_as_str() {
assert_eq!(CacheTTL::FiveMinutes.as_str(), "5m");
assert_eq!(CacheTTL::OneHour.as_str(), "1h");
}
#[test]
fn test_retry_config_defaults() {
let retry = RetryConfig::default();
assert_eq!(retry.max_retries, 5);
assert_eq!(retry.initial_backoff_ms, 1000);
assert_eq!(retry.max_backoff_ms, 60000);
assert_eq!(retry.backoff_multiplier, 2.0);
assert!(retry.jitter);
}
#[test]
fn test_retry_config_backoff_calculation() {
let retry = RetryConfig {
max_retries: 5,
initial_backoff_ms: 1000,
max_backoff_ms: 60000,
backoff_multiplier: 2.0,
jitter: false, };
assert_eq!(retry.calculate_backoff(1), 1000);
assert_eq!(retry.calculate_backoff(2), 2000);
assert_eq!(retry.calculate_backoff(3), 4000);
assert_eq!(retry.calculate_backoff(4), 8000);
assert_eq!(retry.calculate_backoff(5), 16000);
}
#[test]
fn test_retry_config_backoff_capped_at_max() {
let retry = RetryConfig {
max_retries: 10,
initial_backoff_ms: 1000,
max_backoff_ms: 10000, backoff_multiplier: 2.0,
jitter: false,
};
assert_eq!(retry.calculate_backoff(5), 10000);
assert_eq!(retry.calculate_backoff(10), 10000);
}
#[test]
fn test_retry_config_with_jitter() {
let retry = RetryConfig {
max_retries: 5,
initial_backoff_ms: 1000,
max_backoff_ms: 60000,
backoff_multiplier: 2.0,
jitter: true,
};
let backoff = retry.calculate_backoff(1);
assert!(
(500..=1500).contains(&backoff),
"Backoff with jitter out of range: {}",
backoff
);
let backoff1 = retry.calculate_backoff(2);
let backoff2 = retry.calculate_backoff(2);
assert!(
(1000..=3000).contains(&backoff1),
"Backoff1 out of range: {}",
backoff1
);
assert!(
(1000..=3000).contains(&backoff2),
"Backoff2 out of range: {}",
backoff2
);
}
#[test]
fn test_anthropic_config_with_retry() {
let config = AnthropicConfig::default();
assert!(config.retry.is_some());
let retry = config.retry.unwrap();
assert_eq!(retry.max_retries, 5);
}
#[test]
fn test_anthropic_config_without_retry() {
let config = AnthropicConfig {
retry: None,
..Default::default()
};
assert!(config.retry.is_none());
}
#[test]
fn test_azure_anthropic_auth_method_parsing() {
assert_eq!(
"x_api_key".parse::<AzureAnthropicAuthMethod>().unwrap(),
AzureAnthropicAuthMethod::XApiKey
);
assert_eq!(
"x-api-key".parse::<AzureAnthropicAuthMethod>().unwrap(),
AzureAnthropicAuthMethod::XApiKey
);
assert_eq!(
"bearer".parse::<AzureAnthropicAuthMethod>().unwrap(),
AzureAnthropicAuthMethod::BearerToken
);
assert_eq!(
"bearer_token".parse::<AzureAnthropicAuthMethod>().unwrap(),
AzureAnthropicAuthMethod::BearerToken
);
assert!("invalid".parse::<AzureAnthropicAuthMethod>().is_err());
}
#[test]
fn test_azure_anthropic_base_url_from_resource() {
let derived = AzureAnthropicConfig::base_url_from_resource("winfunc-agent").unwrap();
assert_eq!(
derived,
"https://winfunc-agent.services.ai.azure.com/anthropic"
);
}
#[test]
fn test_azure_anthropic_base_url_normalization() {
let config = AzureAnthropicConfig {
base_url: "https://winfunc-agent.services.ai.azure.com/anthropic/v1/messages/"
.to_string(),
auth_method: AzureAnthropicAuthMethod::XApiKey,
};
assert_eq!(
config.normalized_base_url().unwrap(),
"https://winfunc-agent.services.ai.azure.com/anthropic"
);
}
#[test]
fn test_validate_rejects_bedrock_and_azure_together() {
let config = AnthropicConfig {
bedrock: Some(BedrockConfig::default()),
azure: Some(AzureAnthropicConfig {
base_url: "https://winfunc-agent.services.ai.azure.com/anthropic".to_string(),
auth_method: AzureAnthropicAuthMethod::XApiKey,
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_caching_config_top_level_cache_control() {
let marker = CachingConfig::default()
.top_level_cache_control()
.expect("enabled caching should produce a marker");
assert_eq!(marker.cache_type, "ephemeral");
assert_eq!(marker.ttl.as_deref(), Some("5m"));
}
}