diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs new file mode 100644 index 0000000000..364fa1e46b --- /dev/null +++ b/crates/goose-providers/src/anthropic.rs @@ -0,0 +1,290 @@ +use crate::base::ProviderDescriptor; +use crate::errors::ProviderError; +use crate::request_log::{start_log, LoggerHandleExt}; +use anyhow::Result; +use async_stream::try_stream; +use async_trait::async_trait; +use futures::TryStreamExt; +use reqwest::StatusCode; +use serde_json::Value; +use std::io; +use tokio::pin; +use tokio_util::io::StreamReader; + +use super::api_client::ApiClient; +use super::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata}; +use super::formats::anthropic::{ + create_request, response_to_streaming_message, AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME, +}; +use super::openai_compatible::handle_status; +use super::openai_compatible::map_http_error_to_provider_error; +use super::retry::ProviderRetry; +use crate::conversation::message::Message; +use crate::model::ModelConfig; +use rmcp::model::Tool; + +pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5"; +pub const ANTHROPIC_DEFAULT_FAST_MODEL: &str = "claude-haiku-4-5"; +const ANTHROPIC_KNOWN_MODELS: &[&str] = &[ + "claude-opus-4-8", + "claude-opus-4-7", + // Claude 4.6 models + "claude-opus-4-6", + "claude-sonnet-4-6", + // Claude 4.5 models with aliases + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", + "claude-opus-4-5", + "claude-opus-4-5-20251101", + // Legacy Claude 4.0 models + "claude-sonnet-4-0", + "claude-sonnet-4-20250514", + "claude-opus-4-0", + "claude-opus-4-20250514", +]; + +const ANTHROPIC_DOC_URL: &str = "https://docs.anthropic.com/en/docs/about-claude/models"; +pub const ANTHROPIC_API_VERSION: &str = "2023-06-01"; + +#[derive(serde::Serialize)] +pub struct AnthropicProvider { + #[serde(skip)] + api_client: ApiClient, + supports_streaming: bool, + name: String, + custom_models: Option>, + dynamic_models: Option, + skip_canonical_filtering: bool, + #[serde(skip)] + format_options: AnthropicFormatOptions, +} + +/// Builder for [`AnthropicProvider`]. +/// +/// Exposes every field of the provider so that constructors living outside +/// `anthropic.rs` (e.g. in `anthropic_def.rs`, which lives in the `goose` +/// crate) can assemble a provider without needing direct access to the +/// struct's private fields. +pub struct AnthropicProviderBuilder { + api_client: ApiClient, + supports_streaming: bool, + name: String, + custom_models: Option>, + dynamic_models: Option, + skip_canonical_filtering: bool, + format_options: AnthropicFormatOptions, +} + +impl AnthropicProviderBuilder { + pub fn new(api_client: ApiClient) -> Self { + Self { + api_client, + supports_streaming: true, + name: ANTHROPIC_PROVIDER_NAME.to_string(), + custom_models: None, + dynamic_models: None, + skip_canonical_filtering: false, + format_options: AnthropicFormatOptions::default(), + } + } + + pub fn supports_streaming(mut self, supports_streaming: bool) -> Self { + self.supports_streaming = supports_streaming; + self + } + + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + pub fn custom_models(mut self, custom_models: Option>) -> Self { + self.custom_models = custom_models; + self + } + + pub fn dynamic_models(mut self, dynamic_models: Option) -> Self { + self.dynamic_models = dynamic_models; + self + } + + pub fn skip_canonical_filtering(mut self, skip_canonical_filtering: bool) -> Self { + self.skip_canonical_filtering = skip_canonical_filtering; + self + } + + pub fn format_options(mut self, format_options: AnthropicFormatOptions) -> Self { + self.format_options = format_options; + self + } + + pub fn build(self) -> AnthropicProvider { + AnthropicProvider { + api_client: self.api_client, + supports_streaming: self.supports_streaming, + name: self.name, + custom_models: self.custom_models, + dynamic_models: self.dynamic_models, + skip_canonical_filtering: self.skip_canonical_filtering, + format_options: self.format_options, + } + } +} + +impl AnthropicProvider { + async fn fetch_models_from_api(&self) -> Result, ProviderError> { + let response = self.api_client.request(None, "v1/models").api_get().await?; + + if response.status == StatusCode::NOT_FOUND { + let msg = response + .payload + .as_ref() + .and_then(|p| p.get("error").and_then(|e| e.get("message"))) + .and_then(|m| m.as_str()) + .unwrap_or("models endpoint not found") + .to_string(); + return Err(ProviderError::EndpointNotFound(msg)); + } + + if response.status != StatusCode::OK { + return Err(map_http_error_to_provider_error( + response.status, + response.payload, + "v1/models", + )); + } + + let json = response.payload.unwrap_or_default(); + let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { + ProviderError::RequestFailed( + "Missing 'data' array in Anthropic models response".to_string(), + ) + })?; + + let mut models: Vec = arr + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) + .collect(); + models.sort(); + Ok(models) + } +} + +impl ProviderDescriptor for AnthropicProvider { + fn metadata() -> ProviderMetadata { + let models: Vec = ANTHROPIC_KNOWN_MODELS + .iter() + .map(|&model_name| ModelInfo::new(model_name, 200_000)) + .collect(); + + ProviderMetadata::with_models( + ANTHROPIC_PROVIDER_NAME, + "Anthropic", + "Claude and other models from Anthropic", + ANTHROPIC_DEFAULT_MODEL, + models, + ANTHROPIC_DOC_URL, + vec![ + ConfigKey::new("ANTHROPIC_API_KEY", true, true, None, true), + ConfigKey::new( + "ANTHROPIC_HOST", + true, + false, + Some("https://api.anthropic.com"), + false, + ), + ], + ) + .with_fast_model(ANTHROPIC_DEFAULT_FAST_MODEL) + .with_setup_steps(vec![ + "Go to https://platform.claude.com/settings/keys", + "Click 'Create Key'", + "Copy the key and paste it above", + ]) + } +} + +#[async_trait] +impl Provider for AnthropicProvider { + fn get_name(&self) -> &str { + &self.name + } + + fn skip_canonical_filtering(&self) -> bool { + self.skip_canonical_filtering + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + if let Some(custom_models) = &self.custom_models { + if self.dynamic_models == Some(false) { + return Ok(custom_models.clone()); + } + match self.fetch_models_from_api().await { + Ok(models) => return Ok(models), + Err(e) if e.is_endpoint_not_found() => { + tracing::debug!( + "Models endpoint not implemented for provider '{}' ({}), using predefined list", + self.name, + e + ); + return Ok(custom_models.clone()); + } + Err(e) => return Err(e), + } + } + + self.fetch_models_from_api().await + } + + async fn stream( + &self, + model_config: &ModelConfig, + session_id: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let mut payload = create_request( + ANTHROPIC_PROVIDER_NAME, + model_config, + system, + messages, + tools, + self.format_options, + )?; + payload + .as_object_mut() + .unwrap() + .insert("stream".to_string(), Value::Bool(true)); + + let mut log = start_log(model_config, &payload)?; + + let response = self + .with_retry(|| async { + let request = self.api_client.request(Some(session_id), "v1/messages"); + let resp = request.response_post(&payload).await?; + handle_status(resp).await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + let stream = response.bytes_stream().map_err(io::Error::other); + + Ok(Box::pin(try_stream! { + let stream_reader = StreamReader::new(stream); + let framed = tokio_util::codec::FramedRead::new(stream_reader, tokio_util::codec::LinesCodec::new()).map_err(anyhow::Error::from); + + let message_stream = response_to_streaming_message(framed); + pin!(message_stream); + while let Some(message) = futures::StreamExt::next(&mut message_stream).await { + let (message, usage) = message.map_err(ProviderError::from_stream_error)?; + log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; + yield (message, usage); + } + })) + } +} diff --git a/crates/goose-providers/src/formats.rs b/crates/goose-providers/src/formats.rs index 4b7d8f21a8..838906cff4 100644 --- a/crates/goose-providers/src/formats.rs +++ b/crates/goose-providers/src/formats.rs @@ -1,2 +1,3 @@ +pub mod anthropic; pub mod openai; pub mod openai_responses; diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose-providers/src/formats/anthropic.rs similarity index 95% rename from crates/goose/src/providers/formats/anthropic.rs rename to crates/goose-providers/src/formats/anthropic.rs index 7505888258..d26ba4d09f 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose-providers/src/formats/anthropic.rs @@ -1,13 +1,13 @@ +use crate::canonical::maybe_get_canonical_model; +use crate::canonical::ThinkingMode; use crate::conversation::message::{Message, MessageContent}; +use crate::conversation::token_usage::{ProviderUsage, Usage}; +use crate::errors::ProviderError; +use crate::images::{convert_image, ImageFormat}; use crate::mcp_utils::extract_text_from_resource; -use crate::providers::canonical::maybe_get_canonical_model; +use crate::model::ModelConfig; +use crate::thinking::ThinkingEffort; use anyhow::{anyhow, Result}; -use goose_providers::canonical::ThinkingMode; -use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; -use goose_providers::errors::ProviderError; -use goose_providers::images::{convert_image, ImageFormat}; -use goose_providers::model::ModelConfig; -use goose_providers::thinking::ThinkingEffort; use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, JsonObject, Role, Tool}; use rmcp::object as json_object; use serde_json::{json, Value}; @@ -16,7 +16,7 @@ use std::fmt; use std::str::FromStr; use std::sync::Arc; -pub(crate) const ANTHROPIC_PROVIDER_NAME: &str = "anthropic"; +pub const ANTHROPIC_PROVIDER_NAME: &str = "anthropic"; macro_rules! string_enum { ($name:ident { $($variant:ident => $str:literal),+ $(,)? }) => { @@ -54,19 +54,9 @@ impl AnthropicFormatOptions { fn for_model(self, model_config: &ModelConfig) -> Self { let preserve_thinking_context = model_config .request_param::("preserve_thinking_context") - .or_else(|| { - crate::config::Config::global() - .get_param("ANTHROPIC_PRESERVE_THINKING_CONTEXT") - .ok() - }) .unwrap_or(self.preserve_thinking_context); let preserve_unsigned_thinking = model_config .request_param::("preserve_unsigned_thinking") - .or_else(|| { - crate::config::Config::global() - .get_param("ANTHROPIC_PRESERVE_UNSIGNED_THINKING") - .ok() - }) .unwrap_or(self.preserve_unsigned_thinking) || preserve_thinking_context; let thinking_disabled = model_config.reasoning == Some(false) @@ -115,7 +105,7 @@ pub fn thinking_type_for_provider(provider_name: &str, model_config: &ModelConfi let effort = model_config.thinking_effort(); - if effort.is_none() && legacy_thinking_budget_tokens().is_some() { + if effort.is_none() && model_config.request_param::("budget_tokens").is_some() { return match mode { Some(ThinkingMode::Adaptive) => ThinkingType::Adaptive, _ => ThinkingType::Enabled, @@ -550,10 +540,6 @@ pub fn thinking_budget_tokens(model_config: &ModelConfig) -> i32 { return request_param.max(1024); } - if let Some(budget) = legacy_thinking_budget_tokens() { - return budget; - } - let effort = model_config .thinking_effort() .unwrap_or(ThinkingEffort::High); @@ -566,16 +552,6 @@ pub fn thinking_budget_tokens(model_config: &ModelConfig) -> i32 { } } -fn legacy_thinking_budget_tokens() -> Option { - let config = crate::config::Config::global(); - for key in ["ANTHROPIC_THINKING_BUDGET", "CLAUDE_THINKING_BUDGET"] { - if let Ok(budget) = config.get_param::(key) { - return Some(budget.max(1024)); - } - } - None -} - // Anthropic counts thinking tokens against max_tokens, so the budget must leave // room for a response. Clamp it to preserve at least this many answer tokens, and // drop thinking only when even a minimal budget wouldn't fit under the cap. @@ -632,40 +608,7 @@ fn apply_thinking_config( } } -/// Create a complete request payload for Anthropic's API pub fn create_request( - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], -) -> Result { - create_request_with_options( - model_config, - system, - messages, - tools, - AnthropicFormatOptions::default(), - ) -} - -pub fn create_request_with_options( - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], - options: AnthropicFormatOptions, -) -> Result { - create_request_with_options_for_provider( - ANTHROPIC_PROVIDER_NAME, - model_config, - system, - messages, - tools, - options, - ) -} - -pub fn create_request_with_options_for_provider( provider_name: &str, model_config: &ModelConfig, system: &str, @@ -894,10 +837,10 @@ where let parsed_args = if args.is_empty() { json!({}) } else { - match goose_providers::json::parse_tool_arguments(&args) { + match crate::json::parse_tool_arguments(&args) { Some(parsed) => parsed, None => { - let message_text = goose_providers::json::truncation_error_message(&args) + let message_text = crate::json::truncation_error_message(&args) .unwrap_or_else(|| { format!("Could not parse tool arguments: {args}") }); @@ -1041,10 +984,43 @@ where mod tests { use super::*; use crate::conversation::message::Message; - use goose_providers::model::ModelConfig; + use crate::model::ModelConfig; use rmcp::object; use serde_json::json; + /// Create a complete request payload for Anthropic's API + fn create_request_with_default_options( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + create_request_with_options_provider( + model_config, + system, + messages, + tools, + AnthropicFormatOptions::default(), + ) + } + + fn create_request_with_options_provider( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + options: AnthropicFormatOptions, + ) -> Result { + create_request( + ANTHROPIC_PROVIDER_NAME, + model_config, + system, + messages, + tools, + options, + ) + } + #[test] fn test_parse_text_response() -> Result<()> { let response = json!({ @@ -1320,7 +1296,7 @@ mod tests { config.max_tokens = Some(4096); config.request_params = Some(params); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["type"], "adaptive"); assert_eq!(payload["output_config"]["effort"], "high"); @@ -1340,7 +1316,7 @@ mod tests { config.max_tokens = Some(64000); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["type"], "enabled"); let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); @@ -1363,7 +1339,7 @@ mod tests { // Budget larger than max_tokens is clamped to leave room for a response. config.max_tokens = Some(4096); - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; let budget = payload["thinking"]["budget_tokens"].as_i64().unwrap(); assert!(budget >= 1024); assert!(budget <= 4096 - 1024); @@ -1371,7 +1347,7 @@ mod tests { // Too small to fit any thinking alongside a response — drop it. config.max_tokens = Some(1500); - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert!(payload.get("thinking").is_none()); assert_eq!(payload["max_tokens"], 1500); @@ -1387,7 +1363,7 @@ mod tests { let config = cfg_with_effort("claude-sonnet-4-20250514", "off"); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert!(payload.get("thinking").is_none()); assert!(payload.get("output_config").is_none()); @@ -1398,10 +1374,7 @@ mod tests { #[test] fn test_create_request_preserves_thinking_context_for_compatible_models() -> Result<()> { let _guard = env_lock::lock_env([ - ("CLAUDE_THINKING_TYPE", None::<&str>), ("CLAUDE_THINKING_ENABLED", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", None::<&str>), - ("CLAUDE_THINKING_BUDGET", None::<&str>), ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ("ANTHROPIC_PRESERVE_UNSIGNED_THINKING", None::<&str>), ]); @@ -1413,7 +1386,7 @@ mod tests { Message::user().with_text("Continue"), ]; - let payload = create_request_with_options( + let payload = create_request_with_options_provider( &config, "system", &messages, @@ -1441,10 +1414,7 @@ mod tests { #[test] fn test_create_request_model_params_enable_preserved_thinking_context() -> Result<()> { let _guard = env_lock::lock_env([ - ("CLAUDE_THINKING_TYPE", None::<&str>), ("CLAUDE_THINKING_ENABLED", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", None::<&str>), - ("CLAUDE_THINKING_BUDGET", None::<&str>), ("ANTHROPIC_PRESERVE_THINKING_CONTEXT", None::<&str>), ("ANTHROPIC_PRESERVE_UNSIGNED_THINKING", None::<&str>), ]); @@ -1460,7 +1430,7 @@ mod tests { Message::user().with_text("Continue"), ]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["clear_thinking"], false); assert_eq!(payload["messages"][0]["content"][0]["type"], "thinking"); @@ -1716,7 +1686,7 @@ mod tests { config.temperature = Some(0.7); let messages = vec![Message::user().with_text("Hello")]; - let payload = create_request(&config, "system", &messages, &[])?; + let payload = create_request_with_default_options(&config, "system", &messages, &[])?; assert_eq!(payload["thinking"]["type"], "adaptive"); assert!(payload.get("temperature").is_none()); @@ -1725,17 +1695,6 @@ mod tests { Ok(()) } - #[test] - fn test_thinking_budget_uses_legacy_env() { - let _guard = env_lock::lock_env([ - ("GOOSE_THINKING_EFFORT", None::<&str>), - ("ANTHROPIC_THINKING_BUDGET", Some("8192")), - ("CLAUDE_THINKING_BUDGET", None::<&str>), - ]); - let config = cfg_with_effort("claude-3-7-sonnet-20250219", "high"); - assert_eq!(thinking_budget_tokens(&config), 8192); - } - #[test] fn test_thinking_type_non_claude_always_disabled() { assert_eq!( diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index e450c7c08f..991ae9efa6 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -1,3 +1,4 @@ +pub mod anthropic; pub mod api_client; pub mod base; pub mod canonical; diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index fd5b389c25..72a5eb4f9d 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -1,6 +1,6 @@ use crate::config::paths::Paths; use crate::config::Config; -use crate::providers::anthropic::AnthropicProvider; +use crate::providers::anthropic_def::AnthropicProviderDef; use crate::providers::base::{ModelInfo, ProviderType}; use crate::providers::huggingface::HuggingFaceProvider; use crate::providers::huggingface_auth; @@ -643,14 +643,14 @@ pub fn register_declarative_provider( ProviderEngine::Anthropic => { let captured = config.clone(); let identity_config = config.clone(); - registry.register_with_name::( + registry.register_with_name::( &config, provider_type, config.dynamic_models.unwrap_or(false), move |tls_config| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; - AnthropicProvider::from_custom_config(cfg, tls_config) + crate::providers::anthropic_def::from_custom_config(cfg, tls_config) }, move || { let mut cfg = identity_config.clone(); diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs deleted file mode 100644 index e913fbaeb2..0000000000 --- a/crates/goose/src/providers/anthropic.rs +++ /dev/null @@ -1,426 +0,0 @@ -use anyhow::Result; -use async_stream::try_stream; -use async_trait::async_trait; -use futures::TryStreamExt; -use goose_providers::base::ProviderDescriptor; -use goose_providers::errors::ProviderError; -use goose_providers::request_log::{start_log, LoggerHandleExt}; -use reqwest::StatusCode; -use serde_json::Value; -use std::io; -use tokio::pin; -use tokio_util::io::StreamReader; - -use super::api_client::{ApiClient, AuthMethod}; -use super::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata}; -use super::formats::anthropic::{ - create_request_with_options_for_provider, response_to_streaming_message, - AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME, -}; -use super::openai_compatible::handle_status; -use super::openai_compatible::map_http_error_to_provider_error; -use super::retry::ProviderRetry; -use crate::config::declarative_providers::DeclarativeProviderConfig; -use crate::conversation::message::Message; -use futures::future::BoxFuture; -use goose_providers::model::ModelConfig; -use rmcp::model::Tool; - -pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5"; -const ANTHROPIC_KNOWN_MODELS: &[&str] = &[ - // Claude 4.6 models - "claude-opus-4-6", - "claude-sonnet-4-6", - // Claude 4.5 models with aliases - "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", - "claude-haiku-4-5", - "claude-haiku-4-5-20251001", - "claude-opus-4-5", - "claude-opus-4-5-20251101", - // Legacy Claude 4.0 models - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", - "claude-opus-4-0", - "claude-opus-4-20250514", -]; - -const ANTHROPIC_DOC_URL: &str = "https://docs.anthropic.com/en/docs/about-claude/models"; -const ANTHROPIC_API_VERSION: &str = "2023-06-01"; -const ANTHROPIC_DEFAULT_FAST_MODEL: &str = "claude-haiku-4-5"; - -#[derive(serde::Serialize)] -pub struct AnthropicProvider { - #[serde(skip)] - api_client: ApiClient, - supports_streaming: bool, - name: String, - custom_models: Option>, - dynamic_models: Option, - skip_canonical_filtering: bool, - #[serde(skip)] - format_options: AnthropicFormatOptions, -} - -impl AnthropicProvider { - pub async fn from_env( - tls_config: Option, - ) -> Result { - let config = crate::config::Config::global(); - let api_key: String = config.get_secret("ANTHROPIC_API_KEY")?; - let host: String = config - .get_param("ANTHROPIC_HOST") - .unwrap_or_else(|_| "https://api.anthropic.com".to_string()); - - let auth = AuthMethod::ApiKey { - header_name: "x-api-key".to_string(), - key: api_key, - }; - - let api_client = ApiClient::new_with_tls(host, auth, tls_config)? - .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; - - Ok(Self { - api_client, - supports_streaming: true, - name: ANTHROPIC_PROVIDER_NAME.to_string(), - custom_models: None, - dynamic_models: None, - skip_canonical_filtering: false, - format_options: AnthropicFormatOptions::default(), - }) - } - - pub fn from_custom_config( - config: DeclarativeProviderConfig, - tls_config: Option, - ) -> Result { - let custom_models = if !config.models.is_empty() { - Some( - config - .models - .iter() - .map(|m| m.name.clone()) - .collect::>(), - ) - } else { - None - }; - - if config.dynamic_models == Some(false) && custom_models.is_none() { - return Err(anyhow::anyhow!( - "Provider '{}' has dynamic_models: false but no static models listed; \ - at least one entry in `models` is required.", - config.name - )); - } - - let global_config = crate::config::Config::global(); - let api_key: String = global_config - .get_secret(&config.api_key_env) - .map_err(|_| anyhow::anyhow!("Missing API key: {}", config.api_key_env))?; - - let auth = AuthMethod::ApiKey { - header_name: "x-api-key".to_string(), - key: api_key, - }; - - let format_options = Self::format_options_for_provider(config.preserves_thinking); - - let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)? - .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; - - if let Some(headers) = &config.headers { - let mut header_map = reqwest::header::HeaderMap::new(); - for (key, value) in headers { - let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; - let header_value = reqwest::header::HeaderValue::from_str(value)?; - header_map.insert(header_name, header_value); - } - api_client = api_client.with_headers(header_map)?; - } - - let supports_streaming = config.supports_streaming.unwrap_or(true); - - if !supports_streaming { - return Err(anyhow::anyhow!( - "Anthropic provider does not support non-streaming mode. All Claude models support streaming. \ - Please remove 'supports_streaming: false' from your provider configuration." - )); - } - - Ok(Self { - api_client, - supports_streaming, - name: config.name.clone(), - custom_models, - dynamic_models: config.dynamic_models, - skip_canonical_filtering: config.skip_canonical_filtering, - format_options, - }) - } - - fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { - AnthropicFormatOptions { - preserve_unsigned_thinking: preserves_thinking, - preserve_thinking_context: preserves_thinking, - thinking_disabled: false, - } - } - - async fn fetch_models_from_api(&self) -> Result, ProviderError> { - let response = self.api_client.request(None, "v1/models").api_get().await?; - - if response.status == StatusCode::NOT_FOUND { - let msg = response - .payload - .as_ref() - .and_then(|p| p.get("error").and_then(|e| e.get("message"))) - .and_then(|m| m.as_str()) - .unwrap_or("models endpoint not found") - .to_string(); - return Err(ProviderError::EndpointNotFound(msg)); - } - - if response.status != StatusCode::OK { - return Err(map_http_error_to_provider_error( - response.status, - response.payload, - "v1/models", - )); - } - - let json = response.payload.unwrap_or_default(); - let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { - ProviderError::RequestFailed( - "Missing 'data' array in Anthropic models response".to_string(), - ) - })?; - - let mut models: Vec = arr - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) - .collect(); - models.sort(); - Ok(models) - } -} - -impl ProviderDescriptor for AnthropicProvider { - fn metadata() -> ProviderMetadata { - let models: Vec = ANTHROPIC_KNOWN_MODELS - .iter() - .map(|&model_name| ModelInfo::new(model_name, 200_000)) - .collect(); - - ProviderMetadata::with_models( - ANTHROPIC_PROVIDER_NAME, - "Anthropic", - "Claude and other models from Anthropic", - ANTHROPIC_DEFAULT_MODEL, - models, - ANTHROPIC_DOC_URL, - vec![ - ConfigKey::new("ANTHROPIC_API_KEY", true, true, None, true), - ConfigKey::new( - "ANTHROPIC_HOST", - true, - false, - Some("https://api.anthropic.com"), - false, - ), - ], - ) - .with_setup_steps(vec![ - "Go to https://platform.claude.com/settings/keys", - "Click 'Create Key'", - "Copy the key and paste it above", - ]) - .with_fast_model(ANTHROPIC_DEFAULT_FAST_MODEL) - } -} - -impl ProviderDef for AnthropicProvider { - type Provider = Self; - - fn from_env( - _extensions: Vec, - tls_config: Option, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(tls_config)) - } -} - -#[async_trait] -impl Provider for AnthropicProvider { - fn get_name(&self) -> &str { - &self.name - } - - fn skip_canonical_filtering(&self) -> bool { - self.skip_canonical_filtering - } - - async fn fetch_supported_models(&self) -> Result, ProviderError> { - if let Some(custom_models) = &self.custom_models { - if self.dynamic_models == Some(false) { - return Ok(custom_models.clone()); - } - match self.fetch_models_from_api().await { - Ok(models) => return Ok(models), - Err(e) if e.is_endpoint_not_found() => { - tracing::debug!( - "Models endpoint not implemented for provider '{}' ({}), using predefined list", - self.name, - e - ); - return Ok(custom_models.clone()); - } - Err(e) => return Err(e), - } - } - - self.fetch_models_from_api().await - } - - async fn stream( - &self, - model_config: &ModelConfig, - session_id: &str, - system: &str, - messages: &[Message], - tools: &[Tool], - ) -> Result { - let mut payload = create_request_with_options_for_provider( - ANTHROPIC_PROVIDER_NAME, - model_config, - system, - messages, - tools, - self.format_options, - )?; - payload - .as_object_mut() - .unwrap() - .insert("stream".to_string(), Value::Bool(true)); - - let mut log = start_log(model_config, &payload)?; - - let response = self - .with_retry(|| async { - let request = self.api_client.request(Some(session_id), "v1/messages"); - let resp = request.response_post(&payload).await?; - handle_status(resp).await - }) - .await - .inspect_err(|e| { - let _ = log.error(e); - })?; - - let stream = response.bytes_stream().map_err(io::Error::other); - - Ok(Box::pin(try_stream! { - let stream_reader = StreamReader::new(stream); - let framed = tokio_util::codec::FramedRead::new(stream_reader, tokio_util::codec::LinesCodec::new()).map_err(anyhow::Error::from); - - let message_stream = response_to_streaming_message(framed); - pin!(message_stream); - while let Some(message) = futures::StreamExt::next(&mut message_stream).await { - let (message, usage) = message.map_err(ProviderError::from_stream_error)?; - log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; - yield (message, usage); - } - })) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; - use wiremock::matchers::method; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn make_provider_with_server( - server_uri: &str, - custom_models: Option>, - dynamic_models: Option, - ) -> AnthropicProvider { - let auth = AuthMethod::ApiKey { - header_name: "x-api-key".to_string(), - key: "test-key".to_string(), - }; - let api_client = ApiClient::new_with_tls(server_uri.to_string(), auth, None) - .unwrap() - .with_header("anthropic-version", ANTHROPIC_API_VERSION) - .unwrap(); - AnthropicProvider { - api_client, - supports_streaming: true, - name: "custom_anthropic".to_string(), - custom_models, - dynamic_models, - skip_canonical_filtering: false, - format_options: AnthropicFormatOptions::default(), - } - } - - fn base_declarative_config( - models: Vec, - dynamic_models: Option, - ) -> DeclarativeProviderConfig { - DeclarativeProviderConfig { - name: "custom_anthropic".to_string(), - engine: ProviderEngine::Anthropic, - display_name: "Custom Anthropic".to_string(), - description: None, - api_key_env: String::new(), - base_url: "http://localhost:1".to_string(), - models, - headers: None, - timeout_seconds: None, - supports_streaming: Some(true), - requires_auth: false, - catalog_provider_id: None, - base_path: None, - env_vars: None, - dynamic_models, - skip_canonical_filtering: false, - model_doc_link: None, - setup_steps: vec![], - fast_model: None, - preserves_thinking: false, - } - } - - #[tokio::test] - async fn fetch_supported_models_static_only_skips_api() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(500)) - .mount(&server) - .await; - - let provider = make_provider_with_server( - &server.uri(), - Some(vec!["m1".to_string(), "m2".to_string()]), - Some(false), - ); - - let models = provider.fetch_supported_models().await.unwrap(); - assert_eq!(models, vec!["m1".to_string(), "m2".to_string()]); - } - - #[test] - fn from_custom_config_rejects_static_only_without_models() { - let config = base_declarative_config(vec![], Some(false)); - let err = AnthropicProvider::from_custom_config(config, None) - .err() - .expect("expected construction error for dynamic_models: false with empty models"); - let msg = err.to_string(); - assert!( - msg.contains("dynamic_models: false"), - "error message should mention dynamic_models: false; got: {msg}" - ); - } -} diff --git a/crates/goose/src/providers/anthropic_def.rs b/crates/goose/src/providers/anthropic_def.rs new file mode 100644 index 0000000000..941e6c93a3 --- /dev/null +++ b/crates/goose/src/providers/anthropic_def.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use futures::future::BoxFuture; + +use crate::{config::DeclarativeProviderConfig, providers::base::ProviderDef}; +use goose_providers::{ + anthropic::{AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, + api_client::{ApiClient, AuthMethod}, + base::ProviderDescriptor, + formats::anthropic::AnthropicFormatOptions, +}; + +pub struct AnthropicProviderDef; + +impl ProviderDescriptor for AnthropicProviderDef { + fn metadata() -> goose_providers::base::ProviderMetadata { + AnthropicProvider::metadata() + } +} + +impl ProviderDef for AnthropicProviderDef { + type Provider = AnthropicProvider; + + fn from_env( + _extensions: Vec, + tls_config: Option, + ) -> BoxFuture<'static, Result> { + Box::pin(from_env(tls_config)) + } +} + +async fn from_env( + tls_config: Option, +) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("ANTHROPIC_API_KEY")?; + let host: String = config + .get_param("ANTHROPIC_HOST") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()); + + let auth = AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: api_key, + }; + + let api_client = ApiClient::new_with_tls(host, auth, tls_config)? + .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + + Ok(AnthropicProviderBuilder::new(api_client).build()) +} + +pub fn from_custom_config( + config: DeclarativeProviderConfig, + tls_config: Option, +) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + + let global_config = crate::config::Config::global(); + let api_key: String = global_config + .get_secret(&config.api_key_env) + .map_err(|_| anyhow::anyhow!("Missing API key: {}", config.api_key_env))?; + + let auth = AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: api_key, + }; + + let format_options = format_options_for_provider(config.preserves_thinking); + + let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)? + .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } + + let supports_streaming = config.supports_streaming.unwrap_or(true); + + if !supports_streaming { + return Err(anyhow::anyhow!( + "Anthropic provider does not support non-streaming mode. All Claude models support streaming. \ + Please remove 'supports_streaming: false' from your provider configuration." + )); + } + + Ok(AnthropicProviderBuilder::new(api_client) + .supports_streaming(supports_streaming) + .name(config.name.clone()) + .custom_models(custom_models) + .dynamic_models(config.dynamic_models) + .skip_canonical_filtering(config.skip_canonical_filtering) + .format_options(format_options) + .build()) +} + +fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions { + AnthropicFormatOptions { + preserve_unsigned_thinking: preserves_thinking, + preserve_thinking_context: preserves_thinking, + thinking_disabled: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; + use goose_providers::base::{ModelInfo, Provider}; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_provider_with_server( + server_uri: &str, + custom_models: Option>, + dynamic_models: Option, + ) -> AnthropicProvider { + let auth = AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: "test-key".to_string(), + }; + let api_client = ApiClient::new_with_tls(server_uri.to_string(), auth, None) + .unwrap() + .with_header("anthropic-version", ANTHROPIC_API_VERSION) + .unwrap(); + AnthropicProviderBuilder::new(api_client) + .name("custom_anthropic") + .custom_models(custom_models) + .dynamic_models(dynamic_models) + .build() + } + + fn base_declarative_config( + models: Vec, + dynamic_models: Option, + ) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "custom_anthropic".to_string(), + engine: ProviderEngine::Anthropic, + display_name: "Custom Anthropic".to_string(), + description: None, + api_key_env: String::new(), + base_url: "http://localhost:1".to_string(), + models, + headers: None, + timeout_seconds: None, + supports_streaming: Some(true), + requires_auth: false, + catalog_provider_id: None, + base_path: None, + env_vars: None, + dynamic_models, + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + } + } + + #[tokio::test] + async fn fetch_supported_models_static_only_skips_api() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let provider = make_provider_with_server( + &server.uri(), + Some(vec!["m1".to_string(), "m2".to_string()]), + Some(false), + ); + + let models = provider.fetch_supported_models().await.unwrap(); + assert_eq!(models, vec!["m1".to_string(), "m2".to_string()]); + } + + #[test] + fn from_custom_config_rejects_static_only_without_models() { + let config = base_declarative_config(vec![], Some(false)); + let err = from_custom_config(config, None) + .err() + .expect("expected construction error for dynamic_models: false with empty models"); + let msg = err.to_string(); + assert!( + msg.contains("dynamic_models: false"), + "error message should mention dynamic_models: false; got: {msg}" + ); + } +} diff --git a/crates/goose/src/providers/databricks_v2.rs b/crates/goose/src/providers/databricks_v2.rs index 0d0ebd8aae..560d0eb774 100644 --- a/crates/goose/src/providers/databricks_v2.rs +++ b/crates/goose/src/providers/databricks_v2.rs @@ -3,6 +3,7 @@ use async_stream::try_stream; use async_trait::async_trait; use futures::future::BoxFuture; use futures::TryStreamExt; +use goose_providers::formats::anthropic::{AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME}; use goose_providers::formats::openai::{self, extract_reasoning_effort, is_openai_responses_model}; use goose_providers::images::ImageFormat; use serde::Serialize; @@ -291,7 +292,14 @@ impl DatabricksV2Provider { messages: &[Message], tools: &[Tool], ) -> Result { - let mut payload = anthropic::create_request(model_config, system, messages, tools)?; + let mut payload = anthropic::create_request( + ANTHROPIC_PROVIDER_NAME, + model_config, + system, + messages, + tools, + AnthropicFormatOptions::default(), + )?; payload["stream"] = Value::Bool(true); let mut log = start_log(model_config, &payload)?; diff --git a/crates/goose/src/providers/formats/gcpvertexai.rs b/crates/goose/src/providers/formats/gcpvertexai.rs index 4dbca6a031..8afca20130 100644 --- a/crates/goose/src/providers/formats/gcpvertexai.rs +++ b/crates/goose/src/providers/formats/gcpvertexai.rs @@ -2,6 +2,7 @@ use super::{anthropic, google}; use crate::conversation::message::Message; use anyhow::{Context, Result}; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; +use goose_providers::formats::anthropic::AnthropicFormatOptions; use goose_providers::model::ModelConfig; use rmcp::model::Tool; use serde_json::Value; @@ -220,7 +221,14 @@ fn create_anthropic_request( messages: &[Message], tools: &[Tool], ) -> Result { - let mut request = anthropic::create_request(model_config, system, messages, tools)?; + let mut request = anthropic::create_request( + "anthropic", + model_config, + system, + messages, + tools, + AnthropicFormatOptions::default(), + )?; let obj = request .as_object_mut() diff --git a/crates/goose/src/providers/formats/mod.rs b/crates/goose/src/providers/formats/mod.rs index ed5a9c535d..13f0497b9a 100644 --- a/crates/goose/src/providers/formats/mod.rs +++ b/crates/goose/src/providers/formats/mod.rs @@ -1,4 +1,6 @@ -pub mod anthropic; +pub mod anthropic { + pub use goose_providers::formats::anthropic::*; +} #[cfg(feature = "aws-providers")] pub mod bedrock; pub mod databricks; diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 20671fcaa3..d5501c0330 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -9,7 +9,6 @@ use super::local_inference::LocalInferenceProvider; use super::sagemaker_tgi::SageMakerTgiProvider; use super::{ amp_acp::AmpAcpProvider, - anthropic::AnthropicProvider, avian::AvianProvider, azure::AzureProvider, base::{Provider, ProviderMetadata}, @@ -41,6 +40,7 @@ use super::{ xai_oauth::XaiOAuthProvider, }; use crate::config::ExtensionConfig; +use crate::providers::anthropic_def::AnthropicProviderDef; use crate::providers::base::ProviderType; use crate::providers::openai_def::OpenAiProviderDef; use crate::{ @@ -63,7 +63,7 @@ async fn init_registry() -> RwLock { false, Some(registrations::amp_acp_inventory()), ); - registry.register_with_inventory::( + registry.register_with_inventory::( true, Some(registrations::anthropic_inventory()), ); diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index fba0bd1166..378a7d61c2 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -6,6 +6,7 @@ use async_stream::try_stream; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use futures::TryStreamExt; +use goose_providers::formats::anthropic::{AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -389,8 +390,15 @@ impl Provider for KimiCodeProvider { messages: &[Message], tools: &[Tool], ) -> Result { - let mut payload = create_request(model_config, system, messages, tools) - .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; + let mut payload = create_request( + ANTHROPIC_PROVIDER_NAME, + model_config, + system, + messages, + tools, + AnthropicFormatOptions::default(), + ) + .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; payload .as_object_mut() .unwrap() diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index a58731c283..06ba551ed6 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -1,6 +1,9 @@ mod acp_tooling; pub mod amp_acp; -pub mod anthropic; +pub mod anthropic { + pub use goose_providers::anthropic::*; +} +pub mod anthropic_def; pub mod api_client { pub use goose_providers::api_client::*; } diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 9e7a06ca12..144cbfeba1 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -67,7 +67,6 @@ These variables control Claude's reasoning behavior. Supported on Anthropic and | Variable | Purpose | Values | Default | |----------|---------|---------|---------| | `CLAUDE_THINKING_TYPE` | Controls Claude reasoning mode | `adaptive`, `enabled`, `disabled` | `adaptive` for Claude 4.6+ models, otherwise `disabled` | -| `CLAUDE_THINKING_BUDGET` | Maximum tokens allocated for Claude's internal reasoning process when `CLAUDE_THINKING_TYPE=enabled` | Positive integer (minimum 1024) | 16000 | **Examples** @@ -82,7 +81,6 @@ export CLAUDE_THINKING_TYPE=enabled # Explicit extended thinking with a larger budget for complex tasks export CLAUDE_THINKING_TYPE=enabled -export CLAUDE_THINKING_BUDGET=32000 # Disable Claude thinking entirely export CLAUDE_THINKING_TYPE=disabled