mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
move anthropic provider into goose-providers (#9985)
This commit is contained in:
parent
d1af10c5ad
commit
854fed5e5d
14 changed files with 598 additions and 533 deletions
290
crates/goose-providers/src/anthropic.rs
Normal file
290
crates/goose-providers/src/anthropic.rs
Normal 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);
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod anthropic;
|
||||
pub mod openai;
|
||||
pub mod openai_responses;
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod anthropic;
|
||||
pub mod api_client;
|
||||
pub mod base;
|
||||
pub mod canonical;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
213
crates/goose/src/providers/anthropic_def.rs
Normal file
213
crates/goose/src/providers/anthropic_def.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue