diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 5eac6104f8..40d069b04c 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -86,6 +86,12 @@ pub struct DeclarativeProviderConfig { pub base_path: Option, #[serde(default)] pub env_vars: Option>, + /// 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, #[serde(default)] diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 2a97b2ade7..511242c625 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -57,6 +57,7 @@ pub struct AnthropicProvider { supports_streaming: bool, name: String, custom_models: Option>, + dynamic_models: Option, skip_canonical_filtering: bool, } @@ -84,6 +85,7 @@ impl AnthropicProvider { supports_streaming: true, name: ANTHROPIC_PROVIDER_NAME.to_string(), custom_models: None, + dynamic_models: None, skip_canonical_filtering: false, }) } @@ -92,6 +94,26 @@ impl AnthropicProvider { model: ModelConfig, config: DeclarativeProviderConfig, ) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + let global_config = crate::config::Config::global(); let api_key: String = global_config .get_secret(&config.api_key_env) @@ -124,12 +146,6 @@ impl AnthropicProvider { )); } - let custom_models = if !config.models.is_empty() { - Some(config.models.iter().map(|m| m.name.clone()).collect()) - } else { - None - }; - let model = if let Some(ref fast_model_name) = config.fast_model { model.with_fast(fast_model_name, &config.name)? } else { @@ -142,6 +158,7 @@ impl AnthropicProvider { supports_streaming, name: config.name.clone(), custom_models, + dynamic_models: config.dynamic_models, skip_canonical_filtering: config.skip_canonical_filtering, }) } @@ -281,6 +298,9 @@ impl Provider for AnthropicProvider { async fn fetch_supported_models(&self) -> Result, ProviderError> { if let Some(custom_models) = &self.custom_models { + if self.dynamic_models == Some(false) { + return Ok(custom_models.clone()); + } match self.fetch_models_from_api().await { Ok(models) => return Ok(models), Err(e) if e.is_endpoint_not_found() => { @@ -345,3 +365,94 @@ impl Provider for AnthropicProvider { })) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_provider_with_server( + server_uri: &str, + custom_models: Option>, + dynamic_models: Option, + ) -> AnthropicProvider { + let auth = AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: "test-key".to_string(), + }; + let api_client = ApiClient::new(server_uri.to_string(), auth) + .unwrap() + .with_header("anthropic-version", ANTHROPIC_API_VERSION) + .unwrap(); + AnthropicProvider { + api_client, + model: ModelConfig::new_or_fail("claude-test"), + supports_streaming: true, + name: "custom_anthropic".to_string(), + custom_models, + dynamic_models, + skip_canonical_filtering: false, + } + } + + fn base_declarative_config( + models: Vec, + dynamic_models: Option, + ) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "custom_anthropic".to_string(), + engine: ProviderEngine::Anthropic, + display_name: "Custom Anthropic".to_string(), + description: None, + api_key_env: String::new(), + base_url: "http://localhost:1".to_string(), + models, + headers: None, + timeout_seconds: None, + supports_streaming: Some(true), + requires_auth: false, + catalog_provider_id: None, + base_path: None, + env_vars: None, + dynamic_models, + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + } + } + + #[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(ModelConfig::new_or_fail("claude-test"), config) + .err() + .expect("expected construction error for dynamic_models: false with empty models"); + let msg = err.to_string(); + assert!( + msg.contains("dynamic_models: false"), + "error message should mention dynamic_models: false; got: {msg}" + ); + } +} diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 8c42cc4ff1..bf4d975192 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -125,6 +125,7 @@ pub struct OpenAiProvider { supports_streaming: bool, name: String, custom_models: Option>, + dynamic_models: Option, skip_canonical_filtering: bool, } @@ -277,6 +278,7 @@ impl OpenAiProvider { supports_streaming: true, name: OPEN_AI_PROVIDER_NAME.to_string(), custom_models: None, + dynamic_models: None, skip_canonical_filtering: false, }) } @@ -293,6 +295,7 @@ impl OpenAiProvider { supports_streaming: true, name: OPEN_AI_PROVIDER_NAME.to_string(), custom_models: None, + dynamic_models: None, skip_canonical_filtering: false, } } @@ -301,6 +304,26 @@ impl OpenAiProvider { model: ModelConfig, config: DeclarativeProviderConfig, ) -> Result { + let custom_models = if !config.models.is_empty() { + Some( + config + .models + .iter() + .map(|m| m.name.clone()) + .collect::>(), + ) + } else { + None + }; + + if config.dynamic_models == Some(false) && custom_models.is_none() { + return Err(anyhow::anyhow!( + "Provider '{}' has dynamic_models: false but no static models listed; \ + at least one entry in `models` is required.", + config.name + )); + } + let global_config = crate::config::Config::global(); let api_key: Option = if config.requires_auth && !config.api_key_env.is_empty() { @@ -360,12 +383,6 @@ impl OpenAiProvider { api_client = api_client.with_headers(header_map)?; } - let custom_models = if !config.models.is_empty() { - Some(config.models.iter().map(|m| m.name.clone()).collect()) - } else { - None - }; - let model = if let Some(ref fast_model_name) = config.fast_model { model.with_fast(fast_model_name, &config.name)? } else { @@ -382,6 +399,7 @@ impl OpenAiProvider { supports_streaming: config.supports_streaming.unwrap_or(true), name: config.name.clone(), custom_models, + dynamic_models: config.dynamic_models, skip_canonical_filtering: config.skip_canonical_filtering, }) } @@ -657,6 +675,9 @@ impl Provider for OpenAiProvider { async fn fetch_supported_models(&self) -> Result, ProviderError> { if let Some(custom_models) = &self.custom_models { + if self.dynamic_models == Some(false) { + return Ok(custom_models.clone()); + } match self.fetch_models_from_api().await { Ok(models) => return Ok(models), Err(e) if e.is_endpoint_not_found() => { @@ -904,6 +925,7 @@ mod tests { supports_streaming: true, name: name.to_string(), custom_models: None, + dynamic_models: None, skip_canonical_filtering: false, } } @@ -1124,4 +1146,91 @@ mod tests { "chat/completions" )); } + + // ── dynamic_models behavior ───────────────────────────────────────────── + + use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_provider_with_server( + server_uri: &str, + custom_models: Option>, + dynamic_models: Option, + ) -> OpenAiProvider { + OpenAiProvider { + api_client: ApiClient::new(server_uri.to_string(), AuthMethod::NoAuth).unwrap(), + base_path: "v1/chat/completions".to_string(), + organization: None, + project: None, + model: ModelConfig::new_or_fail("test-model"), + custom_headers: None, + supports_streaming: true, + name: "custom_test".to_string(), + custom_models, + dynamic_models, + skip_canonical_filtering: false, + } + } + + fn base_declarative_config( + models: Vec, + dynamic_models: Option, + ) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "custom_test".to_string(), + engine: ProviderEngine::OpenAI, + display_name: "Custom Test".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, + } + } + + #[tokio::test] + async fn fetch_supported_models_static_only_skips_api() { + // Any request to the mock returns 500 — if the fix calls the API, the test fails. + 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 = + OpenAiProvider::from_custom_config(ModelConfig::new_or_fail("test-model"), config) + .expect_err( + "expected construction error for dynamic_models: false with empty models", + ); + let msg = err.to_string(); + assert!( + msg.contains("dynamic_models: false"), + "error message should mention dynamic_models: false; got: {msg}" + ); + } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 574090d508..185ac93987 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4582,6 +4582,7 @@ }, "dynamic_models": { "type": "boolean", + "description": "Controls whether `fetch_supported_models` calls the provider's `/v1/models`\nendpoint or returns the static `models` list directly.\n\n- `Some(false)` + non-empty `models`: return the static list; no API call.\nConstruction fails if `models` is empty.\n- `Some(true)` or `None`: try the API; fall back to `models` on 404.", "nullable": true }, "engine": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 2523090d40..019ed5d86a 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -213,6 +213,14 @@ export type DeclarativeProviderConfig = { catalog_provider_id?: string | null; description?: string | null; display_name: string; + /** + * 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. + */ dynamic_models?: boolean | null; engine: ProviderEngine; env_vars?: Array | null;