diff --git a/.gitignore b/.gitignore index 18911ad4..58df38d8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ logs/ # g3 artifacts requirements.md todo.g3.md +.claude/ diff --git a/config.example.toml b/config.example.toml index 6adf5fdb..68cc5069 100644 --- a/config.example.toml +++ b/config.example.toml @@ -17,6 +17,7 @@ default_provider = "anthropic.default" [providers.anthropic.default] api_key = "your-anthropic-api-key" model = "claude-sonnet-4-5" +# base_url = "https://api.anthropic.com/v1" # Optional: Custom API base URL max_tokens = 64000 temperature = 0.3 # cache_config = "ephemeral" # Optional: Enable prompt caching diff --git a/crates/g3-config/src/lib.rs b/crates/g3-config/src/lib.rs index 6277634e..69edbf64 100644 --- a/crates/g3-config/src/lib.rs +++ b/crates/g3-config/src/lib.rs @@ -62,6 +62,7 @@ pub struct OpenAIConfig { pub struct AnthropicConfig { pub api_key: String, pub model: String, + pub base_url: Option, pub max_tokens: Option, pub temperature: Option, pub cache_config: Option, diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 6f0b51ea..040cb903 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -1220,6 +1220,7 @@ impl Agent { format!("anthropic.{}", name), anthropic_config.api_key.clone(), Some(anthropic_config.model.clone()), + anthropic_config.base_url.clone(), anthropic_config.max_tokens, anthropic_config.temperature, anthropic_config.cache_config.clone(), diff --git a/crates/g3-core/tests/test_preflight_max_tokens.rs b/crates/g3-core/tests/test_preflight_max_tokens.rs index c2d27ab9..53bef6d4 100644 --- a/crates/g3-core/tests/test_preflight_max_tokens.rs +++ b/crates/g3-core/tests/test_preflight_max_tokens.rs @@ -16,6 +16,7 @@ fn create_test_config_with_thinking(thinking_budget: Option) -> Config { anthropic_configs.insert("default".to_string(), g3_config::AnthropicConfig { api_key: "test-key".to_string(), model: "claude-sonnet-4-5".to_string(), + base_url: None, max_tokens: Some(16000), temperature: Some(0.1), cache_config: None, diff --git a/crates/g3-planner/src/llm.rs b/crates/g3-planner/src/llm.rs index 9eb610b0..79c36cc3 100644 --- a/crates/g3-planner/src/llm.rs +++ b/crates/g3-planner/src/llm.rs @@ -47,6 +47,7 @@ pub async fn create_planner_provider( format!("anthropic.{}", config_name), anthropic_config.api_key.clone(), Some(anthropic_config.model.clone()), + anthropic_config.base_url.clone(), anthropic_config.max_tokens, anthropic_config.temperature, anthropic_config.cache_config.clone(), diff --git a/crates/g3-providers/src/anthropic.rs b/crates/g3-providers/src/anthropic.rs index d0258641..2d2d635d 100644 --- a/crates/g3-providers/src/anthropic.rs +++ b/crates/g3-providers/src/anthropic.rs @@ -10,6 +10,7 @@ //! - Proper message format conversion between g3 and Anthropic formats //! - Rate limiting and error handling //! - Native tool calling support +//! - Custom base URL support for proxies or alternative API endpoints //! //! # Usage //! @@ -22,6 +23,7 @@ //! let provider = AnthropicProvider::new( //! "your-api-key".to_string(), //! Some("claude-3-5-sonnet-20241022".to_string()), +//! None, // base_url (uses default Anthropic API) //! Some(4096), //! Some(0.1), //! None, // cache_config @@ -60,9 +62,10 @@ //! async fn main() -> anyhow::Result<()> { //! let provider = AnthropicProvider::new( //! "your-api-key".to_string(), -//! None, -//! None, -//! None, +//! None, // model +//! None, // base_url +//! None, // max_tokens +//! None, // temperature //! None, // cache_config //! None, // enable_1m_context //! None, // thinking_budget_tokens @@ -114,7 +117,7 @@ use crate::{ MessageRole, Tool, ToolCall, Usage, }; -const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; +const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com/v1"; const ANTHROPIC_VERSION: &str = "2023-06-01"; #[derive(Debug, Clone)] @@ -123,6 +126,7 @@ pub struct AnthropicProvider { name: String, api_key: String, model: String, + base_url: String, max_tokens: u32, temperature: f32, cache_config: Option, @@ -134,6 +138,7 @@ impl AnthropicProvider { pub fn new( api_key: String, model: Option, + base_url: Option, max_tokens: Option, temperature: Option, cache_config: Option, @@ -146,14 +151,16 @@ impl AnthropicProvider { .map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?; let model = model.unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string()); + let base_url = base_url.unwrap_or_else(|| DEFAULT_ANTHROPIC_BASE_URL.to_string()); - debug!("Initialized Anthropic provider with model: {}", model); + debug!("Initialized Anthropic provider with model: {}, base_url: {}", model, base_url); Ok(Self { client, name: "anthropic".to_string(), api_key, model, + base_url, max_tokens: max_tokens.unwrap_or(4096), temperature: temperature.unwrap_or(0.1), cache_config, @@ -167,6 +174,7 @@ impl AnthropicProvider { name: String, api_key: String, model: Option, + base_url: Option, max_tokens: Option, temperature: Option, cache_config: Option, @@ -179,14 +187,16 @@ impl AnthropicProvider { .map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?; let model = model.unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string()); + let base_url = base_url.unwrap_or_else(|| DEFAULT_ANTHROPIC_BASE_URL.to_string()); - debug!("Initialized Anthropic provider '{}' with model: {}", name, model); + debug!("Initialized Anthropic provider '{}' with model: {}, base_url: {}", name, model, base_url); Ok(Self { client, name, api_key, model, + base_url, max_tokens: max_tokens.unwrap_or(4096), temperature: temperature.unwrap_or(0.1), cache_config, @@ -196,9 +206,10 @@ impl AnthropicProvider { } fn create_request_builder(&self, streaming: bool) -> RequestBuilder { + let url = format!("{}/messages", self.base_url); let mut builder = self .client - .post(ANTHROPIC_API_URL) + .post(&url) .header("x-api-key", &self.api_key) .header("anthropic-version", ANTHROPIC_VERSION) .header("content-type", "application/json"); @@ -980,7 +991,7 @@ mod tests { #[test] fn test_message_conversion() { let provider = - AnthropicProvider::new("test-key".to_string(), None, None, None, None, None, None).unwrap(); + AnthropicProvider::new("test-key".to_string(), None, None, None, None, None, None, None).unwrap(); let messages = vec![ Message::new( @@ -1004,6 +1015,7 @@ mod tests { let provider = AnthropicProvider::new( "test-key".to_string(), Some("claude-3-haiku-20240307".to_string()), + None, // base_url Some(1000), Some(0.5), None, @@ -1029,7 +1041,7 @@ mod tests { #[test] fn test_tool_conversion() { let provider = - AnthropicProvider::new("test-key".to_string(), None, None, None, None, None, None).unwrap(); + AnthropicProvider::new("test-key".to_string(), None, None, None, None, None, None, None).unwrap(); let tools = vec![Tool { name: "get_weather".to_string(), @@ -1062,7 +1074,7 @@ mod tests { #[test] fn test_cache_control_serialization() { let provider = - AnthropicProvider::new("test-key".to_string(), None, None, None, None, None, None).unwrap(); + AnthropicProvider::new("test-key".to_string(), None, None, None, None, None, None, None).unwrap(); // Test message WITHOUT cache_control let messages_without = vec![Message::new(MessageRole::User, "Hello".to_string())]; @@ -1111,6 +1123,7 @@ mod tests { let provider_without = AnthropicProvider::new( "test-key".to_string(), Some("claude-sonnet-4-5".to_string()), + None, // base_url Some(1000), Some(0.5), None, @@ -1131,6 +1144,7 @@ mod tests { let provider_with = AnthropicProvider::new( "test-key".to_string(), Some("claude-sonnet-4-5".to_string()), + None, // base_url Some(20000), // Sufficient for thinking budget Some(0.5), None, @@ -1161,6 +1175,7 @@ mod tests { let provider = AnthropicProvider::new( "test-key".to_string(), Some("claude-sonnet-4-5".to_string()), + None, // base_url Some(20000), Some(0.5), None, @@ -1214,4 +1229,52 @@ mod tests { assert_eq!(text_content.len(), 1); assert_eq!(text_content[0], "Here is my response."); } + + #[test] + fn test_base_url_configuration() { + // Test with default base_url (None) + let provider_default = AnthropicProvider::new( + "test-key".to_string(), + None, + None, // base_url - should use default + None, + None, + None, + None, + None, + ) + .unwrap(); + assert_eq!(provider_default.base_url, DEFAULT_ANTHROPIC_BASE_URL); + + // Test with custom base_url + let custom_url = "https://custom.api.example.com/v1"; + let provider_custom = AnthropicProvider::new( + "test-key".to_string(), + None, + Some(custom_url.to_string()), + None, + None, + None, + None, + None, + ) + .unwrap(); + assert_eq!(provider_custom.base_url, custom_url); + + // Test new_with_name with custom base_url + let provider_named = AnthropicProvider::new_with_name( + "anthropic.custom".to_string(), + "test-key".to_string(), + Some("claude-3-haiku-20240307".to_string()), + Some(custom_url.to_string()), + None, + None, + None, + None, + None, + ) + .unwrap(); + assert_eq!(provider_named.base_url, custom_url); + assert_eq!(provider_named.name, "anthropic.custom"); + } }