add declarative provider support to goose-providers crate (#9992)

This commit is contained in:
Jack Amadeo
2026-07-01 09:40:49 -07:00
committed by GitHub
parent 006d585546
commit b6349b5125
16 changed files with 1226 additions and 413 deletions
Generated
+1
View File
@@ -5133,6 +5133,7 @@ dependencies = [
"url",
"utoipa 4.2.3",
"uuid",
"wiremock",
]
[[package]]
+1
View File
@@ -62,6 +62,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"
@@ -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(())
}
@@ -0,0 +1,30 @@
{
"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
View File
@@ -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
View File
@@ -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;
@@ -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
@@ -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)?;
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)?;
} 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))
}
+388
View File
@@ -0,0 +1,388 @@
use std::{collections::HashMap, str::FromStr};
use anyhow::Result;
use serde::{Deserialize, Deserializer, Serialize};
use utoipa::ToSchema;
use crate::{
anthropic,
api_client::TlsConfig,
base::{ModelInfo, Provider},
ollama, openai,
};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct EnvVarConfig {
pub name: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub secret: bool,
/// Defaults to the value of `required` if not specified.
/// UIs may use this to feature this config value more prominently.
pub primary: Option<bool>,
pub description: Option<String>,
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ProviderEngine {
#[serde(alias = "openai_compatible")]
OpenAI,
#[serde(alias = "ollama_compatible")]
Ollama,
#[serde(alias = "anthropic_compatible")]
Anthropic,
}
impl FromStr for ProviderEngine {
type Err = anyhow::Error;
fn from_str(engine: &str) -> Result<Self> {
match engine.trim().to_lowercase().as_str() {
"openai" | "openai_compatible" => Ok(Self::OpenAI),
"anthropic" | "anthropic_compatible" => Ok(Self::Anthropic),
"ollama" | "ollama_compatible" => Ok(Self::Ollama),
_ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DeclarativeProviderConfig {
pub name: String,
pub engine: ProviderEngine,
pub display_name: String,
pub description: Option<String>,
#[serde(default)]
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
pub headers: Option<HashMap<String, String>>,
pub timeout_seconds: Option<u64>,
pub supports_streaming: Option<bool>,
#[serde(default = "default_requires_auth")]
pub requires_auth: bool,
#[serde(default)]
pub catalog_provider_id: Option<String>,
#[serde(default)]
pub base_path: Option<String>,
#[serde(default)]
pub env_vars: Option<Vec<EnvVarConfig>>,
/// Controls whether `fetch_supported_models` calls the provider's `/v1/models`
/// endpoint or returns the static `models` list directly.
///
/// - `Some(false)` + non-empty `models`: return the static list; no API call.
/// Construction fails if `models` is empty.
/// - `Some(true)` or `None`: try the API; fall back to `models` on 404.
#[serde(default)]
pub dynamic_models: Option<bool>,
#[serde(default)]
pub skip_canonical_filtering: bool,
#[serde(default, deserialize_with = "deserialize_non_empty_string")]
pub model_doc_link: Option<String>,
#[serde(default)]
pub setup_steps: Vec<String>,
#[serde(default, deserialize_with = "deserialize_non_empty_string")]
pub fast_model: Option<String>,
#[serde(default)]
pub preserves_thinking: bool,
}
fn default_requires_auth() -> bool {
true
}
fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool {
matches!(engine, ProviderEngine::OpenAI)
}
/// Deserialize an optional string, treating empty/whitespace-only values as None.
fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
Ok(opt.filter(|s| !s.trim().is_empty()))
}
impl DeclarativeProviderConfig {
pub fn id(&self) -> &str {
&self.name
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn models(&self) -> &[ModelInfo] {
&self.models
}
}
pub trait KeyResolver {
type Error: std::error::Error + Send + Sync + 'static;
fn resolve_key(&self, key: &str) -> std::result::Result<String, Self::Error>;
}
pub struct EnvKeyResolver;
impl EnvKeyResolver {
pub fn new() -> Self {
EnvKeyResolver {}
}
}
impl Default for EnvKeyResolver {
fn default() -> Self {
Self::new()
}
}
impl KeyResolver for EnvKeyResolver {
type Error = std::env::VarError;
fn resolve_key(&self, key: &str) -> std::result::Result<String, Self::Error> {
std::env::var(key)
}
}
fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result<String> {
let mut result = template.to_string();
for var in env_vars {
let placeholder = format!("${{{}}}", var.name);
if !result.contains(&placeholder) {
continue;
}
let value = match std::env::var(&var.name) {
Ok(value) => value,
Err(_) => match &var.default {
Some(default) => default.clone(),
None if var.required => {
anyhow::bail!("Required environment variable {} is not set", var.name)
}
None => continue,
},
};
result = result.replace(&placeholder, &value);
}
Ok(result)
}
fn resolve_config(config: &mut DeclarativeProviderConfig) -> Result<()> {
if let Some(env_vars) = &config.env_vars {
config.base_url = expand_env_vars(&config.base_url, env_vars)?;
for var in env_vars {
if var.name.ends_with("_STREAMING") {
let value = std::env::var(&var.name)
.ok()
.or_else(|| var.default.clone())
.map(|value| value.eq_ignore_ascii_case("true"));
if let Some(value) = value {
config.supports_streaming = Some(value);
}
}
}
}
Ok(())
}
fn config_from_json(json: &str) -> Result<DeclarativeProviderConfig> {
let raw: serde_json::Value = serde_json::from_str(json)?;
let preserves_thinking_was_set = raw.get("preserves_thinking").is_some();
let mut config: DeclarativeProviderConfig = serde_json::from_value(raw)?;
if !preserves_thinking_was_set {
config.preserves_thinking = should_preserve_thinking_by_default(&config.engine);
}
resolve_config(&mut config)?;
Ok(config)
}
pub fn from_json(
json: &str,
tls_config: Option<TlsConfig>,
key_resolver: impl KeyResolver,
) -> Result<Box<dyn Provider>> {
let config = config_from_json(json)?;
match config.engine {
ProviderEngine::OpenAI => openai::from_declarative_config(config, tls_config, key_resolver)
.map(|provider| Box::new(provider.build()) as Box<dyn Provider>),
ProviderEngine::Ollama => ollama::from_declarative_config(config, tls_config, key_resolver)
.map(|provider| Box::new(provider.build()) as Box<dyn Provider>),
ProviderEngine::Anthropic => {
anthropic::from_declarative_config(config, tls_config, key_resolver)
.map(|provider| Box::new(provider.build()) as Box<dyn Provider>)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn model_json() -> serde_json::Value {
json!({
"name": "test-model",
"context_limit": 4096,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null,
"reasoning": false
})
}
#[test]
fn provider_engine_deserializes_compatible_aliases() {
let openai: DeclarativeProviderConfig = serde_json::from_value(json!({
"name": "test-openai",
"engine": "openai_compatible",
"display_name": "Test OpenAI",
"base_url": "http://localhost:1234",
"models": [model_json()]
}))
.unwrap();
assert_eq!(openai.engine, ProviderEngine::OpenAI);
let anthropic: DeclarativeProviderConfig = serde_json::from_value(json!({
"name": "test-anthropic",
"engine": "anthropic_compatible",
"display_name": "Test Anthropic",
"base_url": "http://localhost:1234",
"models": [model_json()]
}))
.unwrap();
assert_eq!(anthropic.engine, ProviderEngine::Anthropic);
let ollama: DeclarativeProviderConfig = serde_json::from_value(json!({
"name": "test-ollama",
"engine": "ollama_compatible",
"display_name": "Test Ollama",
"base_url": "http://localhost:11434",
"models": [model_json()]
}))
.unwrap();
assert_eq!(ollama.engine, ProviderEngine::Ollama);
}
#[test]
fn from_json_defaults_openai_preserves_thinking_to_true() {
let json = json!({
"name": "test-provider",
"engine": "openai",
"display_name": "Test Provider",
"base_url": "http://localhost:1234/v1/chat/completions",
"models": [model_json()],
"requires_auth": false,
"dynamic_models": false
})
.to_string();
let config = config_from_json(&json).unwrap();
assert!(config.preserves_thinking);
}
#[test]
fn from_json_preserves_explicit_openai_preserves_thinking_false() {
let json = json!({
"name": "test-provider",
"engine": "openai",
"display_name": "Test Provider",
"base_url": "http://localhost:1234/v1/chat/completions",
"models": [model_json()],
"requires_auth": false,
"dynamic_models": false,
"preserves_thinking": false
})
.to_string();
let config = config_from_json(&json).unwrap();
assert!(!config.preserves_thinking);
}
#[test]
fn from_json_expands_base_url_from_env_var_default() {
let _guard = env_lock::lock_env([("TEST_PROVIDER_HOST", None::<&str>)]);
let json = json!({
"name": "test-provider",
"engine": "openai",
"display_name": "Test Provider",
"base_url": "${TEST_PROVIDER_HOST}/v1/chat/completions",
"models": [model_json()],
"requires_auth": false,
"dynamic_models": false,
"env_vars": [{
"name": "TEST_PROVIDER_HOST",
"default": "http://localhost:1234"
}]
})
.to_string();
let provider = from_json(&json, None, EnvKeyResolver).unwrap();
assert_eq!(provider.get_name(), "test-provider");
}
#[tokio::test]
async fn from_json_ollama_returns_static_models_when_dynamic_models_false() {
let json = json!({
"name": "test-ollama",
"engine": "ollama",
"display_name": "Test Ollama",
"base_url": "http://localhost:11434",
"models": [model_json()],
"requires_auth": false,
"dynamic_models": false
})
.to_string();
let provider = from_json(&json, None, EnvKeyResolver).unwrap();
assert_eq!(
provider.fetch_supported_models().await.unwrap(),
vec!["test-model".to_string()]
);
}
#[test]
fn from_json_errors_when_required_env_var_is_missing() {
let _guard = env_lock::lock_env([("TEST_PROVIDER_REQUIRED_HOST", None::<&str>)]);
let json = json!({
"name": "test-provider",
"engine": "openai",
"display_name": "Test Provider",
"base_url": "${TEST_PROVIDER_REQUIRED_HOST}/v1/chat/completions",
"models": [model_json()],
"requires_auth": false,
"dynamic_models": false,
"env_vars": [{
"name": "TEST_PROVIDER_REQUIRED_HOST",
"required": true
}]
})
.to_string();
let err = match from_json(&json, None, EnvKeyResolver) {
Ok(_) => panic!("expected missing required env var error"),
Err(err) => err,
};
assert!(err
.to_string()
.contains("Required environment variable TEST_PROVIDER_REQUIRED_HOST is not set"));
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod api_client;
pub mod base;
pub mod canonical;
pub mod conversation;
pub mod declarative;
pub mod errors;
pub mod formats;
pub mod goose_mode;
+343 -36
View File
@@ -2,8 +2,10 @@ use super::api_client::ApiClient;
use super::base::{ConfigKey, MessageStream, Provider, ProviderMetadata};
use super::openai_compatible::handle_status;
use super::retry::{ProviderRetry, RetryConfig};
use crate::api_client::{AuthMethod, TlsConfig};
use crate::base::ProviderDescriptor;
use crate::conversation::message::Message;
use crate::declarative::{DeclarativeProviderConfig, KeyResolver};
use crate::errors::ProviderError;
use crate::formats::ollama::{create_request, response_to_streaming_message_ollama};
use crate::images::ImageFormat;
@@ -13,7 +15,7 @@ use anyhow::{Error, Result};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::TryStreamExt;
use reqwest::Response;
use reqwest::{Response, StatusCode};
use rmcp::model::Tool;
use serde_json::{json, Value};
use std::time::Duration;
@@ -21,6 +23,7 @@ use tokio::pin;
use tokio_stream::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
use tokio_util::io::StreamReader;
use url::Url;
pub const OLLAMA_PROVIDER_NAME: &str = "ollama";
pub const OLLAMA_HOST: &str = "localhost";
@@ -79,10 +82,88 @@ pub struct OllamaProvider {
#[serde(skip)]
api_client: ApiClient,
name: String,
custom_models: Option<Vec<String>>,
dynamic_models: Option<bool>,
skip_canonical_filtering: bool,
options: OllamaOptions,
}
pub struct OllamaProviderBuilder {
api_client: ApiClient,
name: String,
custom_models: Option<Vec<String>>,
dynamic_models: Option<bool>,
skip_canonical_filtering: bool,
options: OllamaOptions,
}
impl OllamaProviderBuilder {
pub fn new(api_client: ApiClient) -> Self {
Self {
api_client,
name: OLLAMA_PROVIDER_NAME.to_string(),
custom_models: None,
dynamic_models: None,
skip_canonical_filtering: false,
options: OllamaOptions::default(),
}
}
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 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 options(mut self, options: OllamaOptions) -> Self {
self.options = options;
self
}
pub fn build(self) -> OllamaProvider {
OllamaProvider {
api_client: self.api_client,
name: self.name,
custom_models: self.custom_models,
dynamic_models: self.dynamic_models,
skip_canonical_filtering: self.skip_canonical_filtering,
options: self.options,
}
}
}
impl OllamaProvider {
pub fn new(
api_client: ApiClient,
@@ -90,12 +171,58 @@ impl OllamaProvider {
skip_canonical_filtering: bool,
options: OllamaOptions,
) -> Self {
Self {
api_client,
name,
skip_canonical_filtering,
options,
OllamaProviderBuilder::new(api_client)
.name(name)
.skip_canonical_filtering(skip_canonical_filtering)
.options(options)
.build()
}
pub fn with_options(mut self, options: OllamaOptions) -> Self {
self.options = options;
self
}
async fn fetch_models_from_api(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.request("api/tags")
.response_get()
.await
.map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?;
if response.status() == StatusCode::NOT_FOUND {
return Err(ProviderError::EndpointNotFound(
"Ollama models endpoint not found".to_string(),
));
}
if !response.status().is_success() {
return Err(ProviderError::RequestFailed(format!(
"Failed to fetch models: HTTP {}",
response.status()
)));
}
let json_response = response.json::<Value>().await.map_err(|e| {
ProviderError::RequestFailed(format!("Failed to parse response: {}", e))
})?;
let models = json_response
.get("models")
.and_then(|m| m.as_array())
.ok_or_else(|| {
ProviderError::RequestFailed("No models array in response".to_string())
})?;
let mut model_names: Vec<String> = models
.iter()
.filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from))
.collect();
model_names.sort();
Ok(model_names)
}
}
@@ -136,6 +263,100 @@ fn apply_ollama_options(payload: &mut Value, options: &OllamaOptions, model_conf
}
}
pub fn from_declarative_config(
config: DeclarativeProviderConfig,
tls_config: Option<TlsConfig>,
key_resolver: impl KeyResolver,
) -> Result<OllamaProviderBuilder> {
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 timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT));
let base_has_scheme =
config.base_url.starts_with("http://") || config.base_url.starts_with("https://");
let base = if base_has_scheme {
config.base_url.clone()
} else {
format!("http://{}", config.base_url)
};
let mut base_url = Url::parse(&base)
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?;
let is_localhost = matches!(base_url.host_str(), Some("localhost" | "127.0.0.1" | "::1"));
if base_url.port().is_none() && !base_has_scheme && is_localhost {
base_url
.set_port(Some(OLLAMA_DEFAULT_PORT))
.map_err(|_| anyhow::anyhow!("Failed to set default port"))?;
}
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::BearerToken(key),
_ => AuthMethod::NoAuth,
};
let mut api_client =
ApiClient::with_timeout_and_tls(base_url.to_string(), auth, timeout, tls_config)?;
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!(
"Ollama provider does not support non-streaming mode. All Ollama models support streaming. \
Please remove 'supports_streaming: false' from your provider configuration."
));
}
Ok(OllamaProviderBuilder::new(api_client)
.name(config.name.clone())
.custom_models(custom_models)
.dynamic_models(config.dynamic_models)
.skip_canonical_filtering(config.skip_canonical_filtering))
}
impl ProviderDescriptor for OllamaProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
@@ -213,39 +434,26 @@ impl Provider for OllamaProvider {
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.request("api/tags")
.response_get()
.await
.map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?;
if let Some(custom_models) = &self.custom_models {
if self.dynamic_models == Some(false) {
return Ok(custom_models.clone());
}
if !response.status().is_success() {
return Err(ProviderError::RequestFailed(format!(
"Failed to fetch models: HTTP {}",
response.status()
)));
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),
}
}
let json_response = response.json::<Value>().await.map_err(|e| {
ProviderError::RequestFailed(format!("Failed to parse response: {}", e))
})?;
let models = json_response
.get("models")
.and_then(|m| m.as_array())
.ok_or_else(|| {
ProviderError::RequestFailed("No models array in response".to_string())
})?;
let mut model_names: Vec<String> = models
.iter()
.filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from))
.collect();
model_names.sort();
Ok(model_names)
self.fetch_models_from_api().await
}
}
@@ -323,6 +531,105 @@ fn stream_ollama(
#[cfg(test)]
mod tests {
use super::*;
use crate::base::ModelInfo;
fn ollama_config(
dynamic_models: Option<bool>,
models: Vec<ModelInfo>,
) -> DeclarativeProviderConfig {
ollama_config_with_base_url(dynamic_models, models, "http://localhost:11434")
}
fn ollama_config_with_base_url(
dynamic_models: Option<bool>,
models: Vec<ModelInfo>,
base_url: &str,
) -> DeclarativeProviderConfig {
DeclarativeProviderConfig {
name: "test-ollama".to_string(),
engine: crate::declarative::ProviderEngine::Ollama,
display_name: "Test Ollama".to_string(),
description: None,
api_key_env: String::new(),
base_url: base_url.to_string(),
models,
headers: None,
timeout_seconds: None,
supports_streaming: None,
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_uses_static_models_when_dynamic_models_false() {
let provider = from_declarative_config(
ollama_config(Some(false), vec![ModelInfo::new("static-model", 4096)]),
None,
crate::declarative::EnvKeyResolver,
)
.unwrap()
.build();
assert_eq!(
provider.fetch_supported_models().await.unwrap(),
vec!["static-model".to_string()]
);
}
#[test]
fn from_custom_config_requires_static_models_when_dynamic_models_false() {
let err = from_declarative_config(
ollama_config(Some(false), vec![]),
None,
crate::declarative::EnvKeyResolver,
)
.err()
.expect("expected static models validation error");
assert!(err
.to_string()
.contains("dynamic_models: false but no static models listed"));
}
#[tokio::test]
async fn fetch_supported_models_falls_back_to_static_models_on_404() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let provider = from_declarative_config(
ollama_config_with_base_url(
None,
vec![ModelInfo::new("static-model", 4096)],
&server.uri(),
),
None,
crate::declarative::EnvKeyResolver,
)
.unwrap()
.build();
assert_eq!(
provider.fetch_supported_models().await.unwrap(),
vec!["static-model".to_string()]
);
}
#[test]
fn test_apply_ollama_options_uses_input_limit() {
+214
View File
@@ -1,8 +1,10 @@
use super::api_client::ApiClient;
use super::base::{ConfigKey, ModelInfo, Provider, ProviderMetadata};
use super::retry::ProviderRetry;
use crate::api_client::{AuthMethod, TlsConfig};
use crate::conversation::message::Message;
use crate::conversation::token_usage::ProviderUsage;
use crate::declarative::{DeclarativeProviderConfig, KeyResolver};
use crate::errors::ProviderError;
use crate::formats::openai::is_openai_responses_model;
use crate::formats::openai::{
@@ -61,6 +63,7 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[
];
pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models";
const DEFAULT_TIMEOUT_SECONDS: u64 = 600;
type OpenAiBaseUrlParts = (String, Vec<(String, String)>, bool);
@@ -178,6 +181,19 @@ impl OpenAiProviderBuilder {
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 base_path(mut self, base_path: impl Into<String>) -> Self {
self.base_path = base_path.into();
self
@@ -687,6 +703,97 @@ impl Provider for OpenAiProvider {
}
}
pub fn from_declarative_config(
config: DeclarativeProviderConfig,
tls_config: Option<TlsConfig>,
key_resolver: impl KeyResolver,
) -> Result<OpenAiProviderBuilder> {
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 normalized_base_url = ensure_url_scheme(&config.base_url);
let url = url::Url::parse(&normalized_base_url)
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?;
let host = url[..url::Position::BeforePath].to_string();
let base_path = if let Some(ref explicit_path) = config.base_path {
explicit_path.trim_start_matches('/').to_string()
} else {
derive_base_path(url.path())
};
let timeout_secs = config.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS);
let auth = match api_key {
Some(key) if !key.is_empty() => AuthMethod::BearerToken(key),
_ => AuthMethod::NoAuth,
};
let mut api_client = ApiClient::with_timeout_and_tls(
host,
auth,
std::time::Duration::from_secs(timeout_secs),
tls_config,
)?;
if let Some(query) = url.query() {
let query_params = url::form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
api_client = api_client.with_query(query_params);
}
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)?;
}
Ok(OpenAiProviderBuilder::new(api_client)
.base_path(base_path)
.custom_headers(config.headers)
.supports_streaming(config.supports_streaming.unwrap_or(true))
.name(config.name.clone())
.custom_models(custom_models)
.dynamic_models(config.dynamic_models)
.skip_canonical_filtering(config.skip_canonical_filtering)
.preserve_thinking_context(config.preserves_thinking))
}
pub fn parse_custom_headers(s: String) -> HashMap<String, String> {
s.split(',')
.filter_map(|header| {
@@ -698,6 +805,26 @@ pub fn parse_custom_headers(s: String) -> HashMap<String, String> {
.collect()
}
pub fn derive_base_path(url_path: &str) -> String {
let stripped = url_path.trim_start_matches('/');
let normalized = stripped.trim_end_matches('/');
if normalized.is_empty() {
"v1/chat/completions".to_string()
} else if normalized.ends_with("chat/completions") {
stripped.to_string()
} else if ends_with_version_segment(normalized) {
format!("{}/chat/completions", normalized)
} else {
format!("{}/v1/chat/completions", normalized)
}
}
fn ends_with_version_segment(path: &str) -> bool {
let last = path.rsplit('/').next().unwrap_or(path);
last.strip_prefix('v')
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -950,6 +1077,60 @@ mod tests {
);
}
fn custom_config(base_url: &str) -> DeclarativeProviderConfig {
DeclarativeProviderConfig {
name: "test-openai".to_string(),
engine: crate::declarative::ProviderEngine::OpenAI,
display_name: "Test OpenAI".to_string(),
description: None,
api_key_env: String::new(),
base_url: base_url.to_string(),
models: vec![crate::base::ModelInfo::new("test-model", 4096)],
headers: None,
timeout_seconds: None,
supports_streaming: None,
requires_auth: false,
catalog_provider_id: None,
base_path: None,
env_vars: None,
dynamic_models: Some(false),
skip_canonical_filtering: false,
model_doc_link: None,
setup_steps: vec![],
fast_model: None,
preserves_thinking: false,
}
}
#[test]
fn from_custom_config_preserves_ipv6_authority() {
let provider = from_declarative_config(
custom_config("http://[::1]:1234/v1"),
None,
crate::declarative::EnvKeyResolver,
)
.unwrap()
.build();
assert_eq!(provider.api_client.host(), "http://[::1]:1234");
}
#[test]
fn from_custom_config_preserves_userinfo_authority() {
let provider = from_declarative_config(
custom_config("https://user:pass@gateway.example/v1"),
None,
crate::declarative::EnvKeyResolver,
)
.unwrap()
.build();
assert_eq!(
provider.api_client.host(),
"https://user:pass@gateway.example"
);
}
#[test]
fn parse_n_ctx_falls_back_to_sole_entry_when_id_differs() {
let body = json!({
@@ -970,4 +1151,37 @@ mod tests {
});
assert_eq!(parse_n_ctx_from_models(&body, "model-c"), None);
}
#[test]
fn derive_base_path_not_removing_api_path() {
let r = derive_base_path("https://opencode.ai/zen/go");
assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions");
}
#[test]
fn derive_base_path_should_support_v1() {
let r = derive_base_path("https://opencode.ai/zen/go/v1");
assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions");
}
#[test]
fn derive_base_path_should_support_no_base_path() {
let r = derive_base_path("https://opencode.ai/");
assert_eq!(r, "https://opencode.ai/v1/chat/completions");
}
#[test]
fn derive_base_path_preserves_non_v1_version_prefix() {
// Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and
// from_custom_config passes url.path() ("/api/paas/v4") here. The
// existing /api/paas/v4 version must not gain an extra /v1 segment.
let r = derive_base_path("/api/paas/v4");
assert_eq!(r, "api/paas/v4/chat/completions");
}
#[test]
fn derive_base_path_does_not_treat_v_word_as_version() {
let r = derive_base_path("/api/voice");
assert_eq!(r, "api/voice/v1/chat/completions");
}
}
@@ -10,126 +10,26 @@ use crate::providers::openai_def::OpenAiProviderDef;
use anyhow::Result;
use include_dir::{include_dir, Dir};
use once_cell::sync::Lazy;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
/// Deserialize an optional string, treating empty/whitespace-only values as None.
fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
Ok(opt.filter(|s| !s.trim().is_empty()))
}
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use utoipa::ToSchema;
pub use goose_providers::declarative::*;
static FIXED_PROVIDERS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/providers/declarative");
pub fn custom_providers_dir() -> std::path::PathBuf {
Paths::config_dir().join("custom_providers")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ProviderEngine {
OpenAI,
Ollama,
Anthropic,
}
impl FromStr for ProviderEngine {
type Err = anyhow::Error;
fn from_str(engine: &str) -> Result<Self> {
match engine.trim().to_lowercase().as_str() {
"openai" | "openai_compatible" => Ok(Self::OpenAI),
"anthropic" | "anthropic_compatible" => Ok(Self::Anthropic),
"ollama" | "ollama_compatible" => Ok(Self::Ollama),
_ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct EnvVarConfig {
pub name: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub secret: bool,
/// When true, the field is shown prominently in the UI (not collapsed).
/// Defaults to the value of `required` if not specified.
pub primary: Option<bool>,
pub description: Option<String>,
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DeclarativeProviderConfig {
pub name: String,
pub engine: ProviderEngine,
pub display_name: String,
pub description: Option<String>,
#[serde(default)]
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
pub headers: Option<HashMap<String, String>>,
pub timeout_seconds: Option<u64>,
pub supports_streaming: Option<bool>,
#[serde(default = "default_requires_auth")]
pub requires_auth: bool,
#[serde(default)]
pub catalog_provider_id: Option<String>,
#[serde(default)]
pub base_path: Option<String>,
#[serde(default)]
pub env_vars: Option<Vec<EnvVarConfig>>,
/// Controls whether `fetch_supported_models` calls the provider's `/v1/models`
/// endpoint or returns the static `models` list directly.
///
/// - `Some(false)` + non-empty `models`: return the static list; no API call.
/// Construction fails if `models` is empty.
/// - `Some(true)` or `None`: try the API; fall back to `models` on 404.
#[serde(default)]
pub dynamic_models: Option<bool>,
#[serde(default)]
pub skip_canonical_filtering: bool,
#[serde(default, deserialize_with = "deserialize_non_empty_string")]
pub model_doc_link: Option<String>,
#[serde(default)]
pub setup_steps: Vec<String>,
#[serde(default, deserialize_with = "deserialize_non_empty_string")]
pub fast_model: Option<String>,
#[serde(default)]
pub preserves_thinking: bool,
}
fn default_requires_auth() -> bool {
true
}
fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool {
matches!(engine, ProviderEngine::OpenAI)
}
impl DeclarativeProviderConfig {
pub fn id(&self) -> &str {
&self.name
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn models(&self) -> &[ModelInfo] {
&self.models
}
}
/// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs.
/// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`.
/// Returns an error if a `required` var is missing.
@@ -464,6 +364,7 @@ pub fn load_provider(id: &str) -> Result<LoadedProvider> {
Err(anyhow::anyhow!("Provider not found: {}", id))
}
pub fn load_custom_providers(dir: &Path) -> Result<Vec<DeclarativeProviderConfig>> {
if !dir.exists() {
return Ok(Vec::new());
+16 -76
View File
@@ -1,12 +1,14 @@
use anyhow::Result;
use futures::future::BoxFuture;
use crate::{config::DeclarativeProviderConfig, providers::base::ProviderDef};
use crate::{
config::{Config, DeclarativeProviderConfig},
providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver},
};
use goose_providers::{
anthropic::{AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION},
api_client::{ApiClient, AuthMethod},
anthropic::{self, AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION},
api_client::{ApiClient, AuthMethod, TlsConfig},
base::ProviderDescriptor,
formats::anthropic::AnthropicFormatOptions,
};
pub struct AnthropicProviderDef;
@@ -51,79 +53,17 @@ async fn from_env(
pub fn from_custom_config(
config: DeclarativeProviderConfig,
tls_config: Option<crate::providers::api_client::TlsConfig>,
tls_config: Option<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_request_builder(crate::session_context::session_id_request_builder())
.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,
}
anthropic::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global()))
.map(|builder| {
builder
.map_api_client(|api_client| {
api_client
.with_request_builder(crate::session_context::session_id_request_builder())
})
.build()
})
}
#[cfg(test)]
@@ -0,0 +1,21 @@
use goose_providers::declarative::KeyResolver;
use crate::config::{Config, ConfigError};
pub struct ConfigKeyResolver<'a> {
config: &'a Config,
}
impl<'a> ConfigKeyResolver<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
}
}
impl<'a> KeyResolver for ConfigKeyResolver<'a> {
type Error = ConfigError;
fn resolve_key(&self, key: &str) -> std::result::Result<String, Self::Error> {
self.config.get_secret(key)
}
}
+1
View File
@@ -28,6 +28,7 @@ pub mod codex;
pub mod codex_acp;
pub mod copilot_acp;
pub mod cursor_agent;
pub mod custom_provider_config;
pub mod databricks;
pub mod databricks_auth;
pub mod databricks_v2;
+19 -64
View File
@@ -5,14 +5,16 @@ use futures::future::BoxFuture;
use url::Url;
use crate::{
config::declarative_providers::DeclarativeProviderConfig, providers::base::ProviderDef,
config::{declarative_providers::DeclarativeProviderConfig, Config},
providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver},
};
use goose_providers::{
api_client::{ApiClient, AuthMethod},
base::ProviderDescriptor,
ollama::{
OllamaOptions, OllamaProvider, OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, OLLAMA_DEFAULT_PORT,
OLLAMA_HOST, OLLAMA_PROVIDER_NAME, OLLAMA_TIMEOUT,
self, OllamaOptions, OllamaProvider, OllamaProviderBuilder,
OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, OLLAMA_DEFAULT_PORT, OLLAMA_HOST, OLLAMA_PROVIDER_NAME,
OLLAMA_TIMEOUT,
},
};
@@ -71,73 +73,26 @@ pub async fn from_env(
)?
.with_request_builder(crate::session_context::session_id_request_builder());
Ok(OllamaProvider::new(
api_client,
OLLAMA_PROVIDER_NAME.to_string(),
false,
options_from_config(),
))
Ok(OllamaProviderBuilder::new(api_client)
.name(OLLAMA_PROVIDER_NAME)
.options(options_from_config())
.build())
}
pub fn from_custom_config(
config: DeclarativeProviderConfig,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<OllamaProvider> {
let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT));
let base = if config.base_url.starts_with("http://") || config.base_url.starts_with("https://")
{
config.base_url.clone()
} else {
format!("http://{}", config.base_url)
};
let mut base_url = Url::parse(&base)
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?;
let explicit_default_port =
config.base_url.ends_with(":80") || config.base_url.ends_with(":443");
let is_https = base_url.scheme() == "https";
if base_url.port().is_none() && !explicit_default_port && !is_https {
base_url
.set_port(Some(OLLAMA_DEFAULT_PORT))
.map_err(|_| anyhow::anyhow!("Failed to set default port"))?;
}
let mut api_client = ApiClient::with_timeout_and_tls(
base_url.to_string(),
AuthMethod::NoAuth,
timeout,
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder());
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!(
"Ollama provider does not support non-streaming mode. All Ollama models support streaming. \
Please remove 'supports_streaming: false' from your provider configuration."
));
}
Ok(OllamaProvider::new(
api_client,
config.name.clone(),
config.skip_canonical_filtering,
options_from_config(),
))
ollama::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global()))
.map(|builder| {
builder
.map_api_client(|api_client| {
api_client
.with_request_builder(crate::session_context::session_id_request_builder())
})
.options(options_from_config())
.build()
})
}
pub fn options_from_config() -> OllamaOptions {
+16 -134
View File
@@ -4,12 +4,13 @@ use goose_providers::base::ProviderDescriptor;
use std::collections::HashMap;
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::config::Config;
use crate::providers::base::{ProviderDef, DEFAULT_PROVIDER_TIMEOUT_SECS};
use crate::providers::custom_provider_config::ConfigKeyResolver;
use goose_providers::api_client::{ApiClient, AuthMethod};
use goose_providers::openai::{
ensure_url_scheme, parse_custom_headers, parse_openai_base_url, OpenAiProvider,
OpenAiProviderBuilder, OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_DEFAULT_FAST_MODEL,
OPEN_AI_VERSIONLESS_BASE_PATH,
parse_custom_headers, parse_openai_base_url, OpenAiProvider, OpenAiProviderBuilder,
OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_VERSIONLESS_BASE_PATH,
};
pub struct OpenAiProviderDef;
@@ -203,85 +204,19 @@ pub fn from_custom_config(
config: DeclarativeProviderConfig,
tls_config: Option<goose_providers::api_client::TlsConfig>,
) -> Result<OpenAiProvider> {
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 = resolve_api_key(&config, &|key| global_config.get_secret(key))?;
let normalized_base_url = ensure_url_scheme(&config.base_url);
let url = url::Url::parse(&normalized_base_url)
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?;
let host = if let Some(port) = url.port() {
format!(
"{}://{}:{}",
url.scheme(),
url.host_str().unwrap_or(""),
port
)
} else {
format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""))
};
let base_path = if let Some(ref explicit_path) = config.base_path {
explicit_path.trim_start_matches('/').to_string()
} else {
derive_base_path(url.path())
};
let timeout_secs = config
.timeout_seconds
.unwrap_or(DEFAULT_PROVIDER_TIMEOUT_SECS);
let auth = match api_key {
Some(key) if !key.is_empty() => AuthMethod::BearerToken(key),
_ => AuthMethod::NoAuth,
};
let mut api_client = ApiClient::with_timeout_and_tls(
host,
auth,
std::time::Duration::from_secs(timeout_secs),
goose_providers::openai::from_declarative_config(
config,
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder());
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)?;
}
Ok(OpenAiProviderBuilder::new(api_client)
.base_path(base_path)
.custom_headers(config.headers)
.supports_streaming(config.supports_streaming.unwrap_or(true))
.name(config.name.clone())
.custom_models(custom_models)
.dynamic_models(config.dynamic_models)
.skip_canonical_filtering(config.skip_canonical_filtering)
.preserve_thinking_context(config.preserves_thinking)
.build())
ConfigKeyResolver::new(Config::global()),
)
.map(|builder| {
builder
.map_api_client(|api_client| {
api_client
.with_request_builder(crate::session_context::session_id_request_builder())
})
.build()
})
}
/// Components extracted from an `OPENAI_BASE_URL` value.
@@ -359,26 +294,6 @@ fn is_direct_openai_host(host: &str) -> bool {
.unwrap_or(false)
}
fn derive_base_path(url_path: &str) -> String {
let stripped = url_path.trim_start_matches('/');
let normalized = stripped.trim_end_matches('/');
if normalized.is_empty() {
"v1/chat/completions".to_string()
} else if normalized.ends_with("chat/completions") {
stripped.to_string()
} else if ends_with_version_segment(normalized) {
format!("{}/chat/completions", normalized)
} else {
format!("{}/v1/chat/completions", normalized)
}
}
fn ends_with_version_segment(path: &str) -> bool {
let last = path.rsplit('/').next().unwrap_or(path);
last.strip_prefix('v')
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -426,12 +341,6 @@ mod tests {
assert!(!r.has_v1);
}
#[test]
fn derive_base_path_not_removing_api_path() {
let r = derive_base_path("https://opencode.ai/zen/go");
assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions");
}
#[test]
fn is_direct_openai_host_matches_only_openai() {
assert!(is_direct_openai_host("https://api.openai.com"));
@@ -442,33 +351,6 @@ mod tests {
assert!(!is_direct_openai_host("https://router.huggingface.co/v1"));
}
#[test]
fn derive_base_path_should_support_v1() {
let r = derive_base_path("https://opencode.ai/zen/go/v1");
assert_eq!(r, "https://opencode.ai/zen/go/v1/chat/completions");
}
#[test]
fn derive_base_path_should_support_no_base_path() {
let r = derive_base_path("https://opencode.ai/");
assert_eq!(r, "https://opencode.ai/v1/chat/completions");
}
#[test]
fn derive_base_path_preserves_non_v1_version_prefix() {
// Zhipu's default base_url is https://open.bigmodel.cn/api/paas/v4 and
// from_custom_config passes url.path() ("/api/paas/v4") here. The
// existing /api/paas/v4 version must not gain an extra /v1 segment.
let r = derive_base_path("/api/paas/v4");
assert_eq!(r, "api/paas/v4/chat/completions");
}
#[test]
fn derive_base_path_does_not_treat_v_word_as_version() {
let r = derive_base_path("/api/voice");
assert_eq!(r, "api/voice/v1/chat/completions");
}
#[test]
fn parse_base_url_preserves_query_params() {
let r = parse_base_url("https://gw.example.com/v1?api-version=2024-02-01").unwrap();