move anthropic provider into goose-providers (#9985)

This commit is contained in:
Jack Amadeo 2026-06-25 16:00:08 -07:00 committed by GitHub
parent d1af10c5ad
commit 854fed5e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 598 additions and 533 deletions

View file

@ -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<Vec<String>>,
dynamic_models: Option<bool>,
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<Vec<String>>,
dynamic_models: Option<bool>,
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<String>) -> Self {
self.name = name.into();
self
}
pub fn custom_models(mut self, custom_models: Option<Vec<String>>) -> Self {
self.custom_models = custom_models;
self
}
pub fn dynamic_models(mut self, dynamic_models: Option<bool>) -> 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<Vec<String>, 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<String> = 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<ModelInfo> = 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<Vec<String>, 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<MessageStream, ProviderError> {
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);
}
}))
}
}

View file

@ -1,2 +1,3 @@
pub mod anthropic;
pub mod openai;
pub mod openai_responses;

View file

@ -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::<bool>("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::<bool>("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::<i32>("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<i32> {
let config = crate::config::Config::global();
for key in ["ANTHROPIC_THINKING_BUDGET", "CLAUDE_THINKING_BUDGET"] {
if let Ok(budget) = config.get_param::<i32>(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<Value> {
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<Value> {
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<Value> {
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<Value> {
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!(

View file

@ -1,3 +1,4 @@
pub mod anthropic;
pub mod api_client;
pub mod base;
pub mod canonical;

View file

@ -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::<AnthropicProvider, _, _>(
registry.register_with_name::<AnthropicProviderDef, _, _>(
&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();

View file

@ -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<Vec<String>>,
dynamic_models: Option<bool>,
skip_canonical_filtering: bool,
#[serde(skip)]
format_options: AnthropicFormatOptions,
}
impl AnthropicProvider {
pub async fn from_env(
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<Self> {
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<crate::providers::api_client::TlsConfig>,
) -> Result<Self> {
let custom_models = if !config.models.is_empty() {
Some(
config
.models
.iter()
.map(|m| m.name.clone())
.collect::<Vec<String>>(),
)
} 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<Vec<String>, 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<String> = 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<ModelInfo> = 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<crate::config::ExtensionConfig>,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
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<Vec<String>, 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<MessageStream, ProviderError> {
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<Vec<String>>,
dynamic_models: Option<bool>,
) -> 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<ModelInfo>,
dynamic_models: Option<bool>,
) -> 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}"
);
}
}

View file

@ -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<crate::config::ExtensionConfig>,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(from_env(tls_config))
}
}
async fn from_env(
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<AnthropicProvider> {
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<crate::providers::api_client::TlsConfig>,
) -> Result<AnthropicProvider> {
let custom_models = if !config.models.is_empty() {
Some(
config
.models
.iter()
.map(|m| m.name.clone())
.collect::<Vec<String>>(),
)
} 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<Vec<String>>,
dynamic_models: Option<bool>,
) -> 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<ModelInfo>,
dynamic_models: Option<bool>,
) -> 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}"
);
}
}

View file

@ -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<MessageStream, ProviderError> {
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)?;

View file

@ -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<Value> {
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()

View file

@ -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;

View file

@ -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<ProviderRegistry> {
false,
Some(registrations::amp_acp_inventory()),
);
registry.register_with_inventory::<AnthropicProvider>(
registry.register_with_inventory::<AnthropicProviderDef>(
true,
Some(registrations::anthropic_inventory()),
);

View file

@ -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<MessageStream, ProviderError> {
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()

View file

@ -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::*;
}

View file

@ -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