Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/goose-providers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
tokio-stream = { workspace = true }
env-lock = { workspace = true }
wiremock.workspace = true

[[example]]
name = "streaming"
Expand Down
33 changes: 33 additions & 0 deletions crates/goose-providers/examples/declarative.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use anyhow::Result;
use futures::StreamExt;
use goose_providers::{
base::Provider, conversation::message::Message, declarative::EnvKeyResolver, model::ModelConfig,
};

async fn complete(provider: &dyn Provider, model: ModelConfig) -> Result<()> {
let system = "You are a knowledgable geography expert";
let messages = [Message::user().with_text("what is the capital of France?")];
let mut stream = provider.stream(&model, system, &messages, &[]).await?;

while let Some((Some(msg), _)) = stream.next().await.transpose()? {
print!("{}", msg.as_concat_text());
}
println!();

Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
let deepseek = include_str!("deepseek.json");
let deepseek_model = ModelConfig::new("deepseek-v4-flash");
let zai = include_str!("zai.json");
let zai_model = ModelConfig::new("glm-4.5-flash");

for (json, model) in [(deepseek, deepseek_model), (zai, zai_model)] {
let provider = goose_providers::declarative::from_json(json, None, EnvKeyResolver {})?;
println!("{}:", provider.get_name());
complete(provider.as_ref(), model).await?;
}
Ok(())
}
30 changes: 30 additions & 0 deletions crates/goose-providers/examples/deepseek.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are copies from what we ship with? how do we keep them in sync?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are, but this is just for example code. Which could drift yes. Once we move all of the declarative definitions into goose-providers, we can switch this to reference those, and they'll have the unit test that checks validity

"name": "deepseek",
"engine": "openai",
"display_name": "DeepSeek",
"description": "Custom DeepSeek provider",
"api_key_env": "DEEPSEEK_API_KEY",
"base_url": "https://api.deepseek.com",
"models": [
{
"name": "deepseek-chat",
"context_limit": 128000,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null
},
{
"name": "deepseek-reasoner",
"context_limit": 128000,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null
}
],
"headers": null,
"timeout_seconds": null,
"preserves_thinking": true,
"supports_streaming": true
}
25 changes: 25 additions & 0 deletions crates/goose-providers/examples/zai.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "zai",
"engine": "anthropic",
"display_name": "Z.AI",
"description": "Z.AI GLM models via Anthropic-compatible API.",
"api_key_env": "ZHIPU_API_KEY",
"base_url": "https://api.z.ai/api/anthropic",
"catalog_provider_id": "zai",
"model_doc_link": "https://docs.z.ai/devpack/tool/goose",
"fast_model": "glm-4.5-air",
"preserves_thinking": true,
"models": [
{ "name": "glm-5.1", "context_limit": 200000 },
{ "name": "glm-5", "context_limit": 204800 },
{ "name": "glm-5-turbo", "context_limit": 200000 },
{ "name": "glm-4.7", "context_limit": 204800 },
{ "name": "glm-4.7-flash", "context_limit": 200000 },
{ "name": "glm-4.7-flashx", "context_limit": 200000 },
{ "name": "glm-4.6", "context_limit": 204800 },
{ "name": "glm-4.5", "context_limit": 131072 },
{ "name": "glm-4.5-air", "context_limit": 131072 },
{ "name": "glm-4.5-flash", "context_limit": 131072 }
],
"supports_streaming": true
}
113 changes: 113 additions & 0 deletions crates/goose-providers/src/anthropic.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::api_client::{AuthMethod, TlsConfig};
use crate::base::ProviderDescriptor;
use crate::declarative::{DeclarativeProviderConfig, KeyResolver};
use crate::errors::ProviderError;
use crate::request_log::{start_log, LoggerHandleExt};
use anyhow::Result;
Expand Down Expand Up @@ -90,6 +92,24 @@ impl AnthropicProviderBuilder {
}
}

pub fn api_client(mut self, api_client: ApiClient) -> Self {
self.api_client = api_client;
self
}

pub fn map_api_client(mut self, f: impl FnOnce(ApiClient) -> ApiClient) -> Self {
self.api_client = f(self.api_client);
self
}

pub fn try_map_api_client(
mut self,
f: impl FnOnce(ApiClient) -> Result<ApiClient>,
) -> Result<Self> {
self.api_client = f(self.api_client)?;
Ok(self)
}

pub fn supports_streaming(mut self, supports_streaming: bool) -> Self {
self.supports_streaming = supports_streaming;
self
Expand Down Expand Up @@ -287,3 +307,96 @@ impl Provider for AnthropicProvider {
}))
}
}

fn format_options_for_provider(preserves_thinking: bool) -> AnthropicFormatOptions {
AnthropicFormatOptions {
preserve_unsigned_thinking: preserves_thinking,
preserve_thinking_context: preserves_thinking,
thinking_disabled: false,
}
}

pub fn from_declarative_config(
config: DeclarativeProviderConfig,
tls_config: Option<TlsConfig>,
key_resolver: impl KeyResolver,
) -> Result<AnthropicProviderBuilder> {
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 api_key = if config.api_key_env.is_empty() {
None
} else {
match key_resolver.resolve_key(config.api_key_env.as_str()) {
Ok(key) => Some(key),
Err(err) => {
if config.requires_auth {
anyhow::bail!("missing required key {}: {}", config.api_key_env, err);
}
None
}
}
};

let auth = match api_key {
Some(key) if !key.is_empty() => AuthMethod::ApiKey {
header_name: "x-api-key".to_string(),
key,
},
_ => AuthMethod::NoAuth,
};

let format_options = format_options_for_provider(config.preserves_thinking);

let mut api_client = ApiClient::new_with_tls(config.base_url, auth, tls_config)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Anthropic base URL query parameters

When an Anthropic-compatible declarative provider uses a base URL with required query parameters (for example a gateway that routes by ?api-version=...), this passes the URL directly as the ApiClient host. ApiClient::build_url later joins v1/messages/v1/models against that host, which drops the base query; unlike the OpenAI constructor, no with_query call re-adds it, so every request is sent without those required parameters.

Useful? React with 👍 / 👎.


if let Some(headers) = &config.headers {
let mut header_map = reqwest::header::HeaderMap::new();
header_map.insert(
reqwest::header::HeaderName::from_static("anthropic-version"),
reqwest::header::HeaderValue::from_static(ANTHROPIC_API_VERSION),
);
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)?;
Comment thread
jamadeo marked this conversation as resolved.
} else {
api_client = api_client.with_header("anthropic-version", ANTHROPIC_API_VERSION)?;
}

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))
}
Loading
Loading