diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 60dfe56599..b8a1dc58a5 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -77,6 +77,10 @@ pub struct DeclarativeProviderConfig { #[serde(default)] pub skip_canonical_filtering: bool, #[serde(default, deserialize_with = "deserialize_non_empty_string")] + pub model_doc_link: Option, + #[serde(default)] + pub setup_steps: Vec, + #[serde(default, deserialize_with = "deserialize_non_empty_string")] pub fast_model: Option, } @@ -233,6 +237,8 @@ pub fn create_custom_provider( env_vars: None, dynamic_models: None, skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], fast_model: None, }; @@ -300,6 +306,8 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> env_vars: existing_config.env_vars, dynamic_models: existing_config.dynamic_models, skip_canonical_filtering: existing_config.skip_canonical_filtering, + model_doc_link: existing_config.model_doc_link, + setup_steps: existing_config.setup_steps, fast_model: existing_config.fast_model.clone(), }; @@ -587,6 +595,33 @@ mod tests { serde_json::from_str(json).expect("groq.json should parse without env_vars"); assert!(config.env_vars.is_none()); assert!(config.dynamic_models.is_none()); + assert!(config.model_doc_link.is_none()); + assert!(config.setup_steps.is_empty()); + } + + #[test] + fn test_nvidia_json_deserializes() { + let json = include_str!("../providers/declarative/nvidia.json"); + let config: DeclarativeProviderConfig = + serde_json::from_str(json).expect("nvidia.json should parse"); + assert_eq!(config.name, "nvidia"); + assert_eq!(config.display_name, "NVIDIA"); + assert!(matches!(config.engine, ProviderEngine::OpenAI)); + assert_eq!(config.api_key_env, "NVIDIA_API_KEY"); + assert_eq!(config.base_url, "https://integrate.api.nvidia.com/v1"); + assert_eq!(config.catalog_provider_id, Some("nvidia".to_string())); + assert_eq!(config.dynamic_models, Some(true)); + assert_eq!(config.supports_streaming, Some(true)); + assert!(!config.skip_canonical_filtering); + assert_eq!( + config.model_doc_link, + Some("https://build.nvidia.com/models".to_string()) + ); + assert_eq!(config.setup_steps.len(), 4); + + assert_eq!(config.models.len(), 1); + assert_eq!(config.models[0].name, "z-ai/glm-4.7"); + assert_eq!(config.models[0].context_limit, 131072); } #[test] diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs index 7a2a11b4d5..eebace1aa8 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose/src/model.rs @@ -158,7 +158,11 @@ impl ModelConfig { self.context_limit = Some(canonical.limit.context); } if self.max_tokens.is_none() { - self.max_tokens = canonical.limit.output.map(|o| o as i32); + self.max_tokens = canonical + .limit + .output + .filter(|&output| output < canonical.limit.context) + .map(|output| output as i32); } if self.reasoning.is_none() { self.reasoning = canonical.reasoning; @@ -491,6 +495,20 @@ mod tests { assert_eq!(config.max_tokens, Some(1_000)); } + #[test] + fn skips_canonical_output_limit_when_it_equals_context_limit() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); + let config = + ModelConfig::new_or_fail("moonshotai/kimi-k2.5").with_canonical_limits("nvidia"); + + assert_eq!(config.context_limit, Some(262_144)); + assert_eq!(config.max_tokens, None); + assert_eq!(config.max_output_tokens(), 4_096); + } + #[test] fn unknown_model_leaves_fields_none() { let _guard = env_lock::lock_env([ diff --git a/crates/goose/src/providers/declarative/nvidia.json b/crates/goose/src/providers/declarative/nvidia.json new file mode 100644 index 0000000000..787422835b --- /dev/null +++ b/crates/goose/src/providers/declarative/nvidia.json @@ -0,0 +1,24 @@ +{ + "name": "nvidia", + "engine": "openai", + "display_name": "NVIDIA", + "description": "Hosted NVIDIA NIM models through the OpenAI-compatible API.", + "api_key_env": "NVIDIA_API_KEY", + "base_url": "https://integrate.api.nvidia.com/v1", + "catalog_provider_id": "nvidia", + "dynamic_models": true, + "models": [ + { + "name": "z-ai/glm-4.7", + "context_limit": 131072 + } + ], + "supports_streaming": true, + "model_doc_link": "https://build.nvidia.com/models", + "setup_steps": [ + "Sign in to https://build.nvidia.com", + "Choose a Free Endpoint model from the model catalog", + "Create an API key", + "Copy the key and paste it above" + ] +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index c4ee348715..b9189a99f1 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -238,6 +238,41 @@ mod tests { assert!(!endpoint.secret, "Endpoint should not be secret"); } + #[tokio::test] + async fn test_nvidia_declarative_provider_registry_wiring() { + let nvidia = get_from_registry("nvidia") + .await + .expect("nvidia provider should be registered"); + let meta = nvidia.metadata(); + + assert_eq!(nvidia.provider_type(), ProviderType::Declarative); + assert!(nvidia.supports_inventory_refresh()); + assert_eq!(meta.display_name, "NVIDIA"); + assert_eq!(meta.default_model, "z-ai/glm-4.7"); + assert_eq!(meta.model_doc_link, "https://build.nvidia.com/models"); + assert!(!meta.setup_steps.is_empty()); + + let api_key = meta + .config_keys + .iter() + .find(|k| k.name == "NVIDIA_API_KEY") + .expect("NVIDIA_API_KEY config key should exist"); + assert!(api_key.required, "NVIDIA_API_KEY should be required"); + assert!(api_key.secret, "NVIDIA_API_KEY should be secret"); + assert!(api_key.primary, "NVIDIA_API_KEY should be primary"); + assert!( + !meta.config_keys.iter().any(|k| k.name == "OPENAI_HOST"), + "NVIDIA should not expose OpenAI host configuration" + ); + assert!( + !meta + .config_keys + .iter() + .any(|k| k.name == "OPENAI_BASE_PATH"), + "NVIDIA should not expose OpenAI base path configuration" + ); + } + #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index 551ca1516f..6a169fa946 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -1,4 +1,4 @@ -use super::base::{ModelInfo, Provider, ProviderDef, ProviderMetadata, ProviderType}; +use super::base::{ConfigKey, ModelInfo, Provider, ProviderDef, ProviderMetadata, ProviderType}; use super::inventory::InventoryIdentityInput; use crate::config::{DeclarativeProviderConfig, ExtensionConfig}; use crate::model::ModelConfig; @@ -165,28 +165,32 @@ impl ProviderRegistry { }) .collect(); - let mut config_keys = base_metadata.config_keys.clone(); - - if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { - if !config.requires_auth { - config_keys.remove(api_key_index); - } else if !config.api_key_env.is_empty() { - let api_key_required = provider_type == ProviderType::Declarative; - config_keys[api_key_index] = super::base::ConfigKey::new( - &config.api_key_env, - api_key_required, - true, - None, - true, - ); + let mut config_keys = if provider_type == ProviderType::Declarative { + if config.requires_auth && !config.api_key_env.is_empty() { + vec![ConfigKey::new(&config.api_key_env, true, true, None, true)] + } else { + Vec::new() } - } + } else { + let mut config_keys = base_metadata.config_keys.clone(); + + if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { + if !config.requires_auth { + config_keys.remove(api_key_index); + } else if !config.api_key_env.is_empty() { + config_keys[api_key_index] = + ConfigKey::new(&config.api_key_env, false, true, None, true); + } + } + + config_keys + }; if let Some(ref env_vars) = config.env_vars { for ev in env_vars { // Default primary to `required` so required fields show prominently in the UI let primary = ev.primary.unwrap_or(ev.required); - config_keys.push(super::base::ConfigKey::new( + config_keys.push(ConfigKey::new( &ev.name, ev.required, ev.secret, @@ -202,9 +206,12 @@ impl ProviderRegistry { description, default_model, known_models, - model_doc_link: base_metadata.model_doc_link, + model_doc_link: config + .model_doc_link + .clone() + .unwrap_or(base_metadata.model_doc_link), config_keys, - setup_steps: vec![], + setup_steps: config.setup_steps.clone(), model_selection_hint: None, }; let inventory_config_keys = custom_metadata.config_keys.clone(); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 05704ece21..d47403ec9b 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4643,6 +4643,10 @@ }, "nullable": true }, + "model_doc_link": { + "type": "string", + "nullable": true + }, "models": { "type": "array", "items": { @@ -4655,6 +4659,12 @@ "requires_auth": { "type": "boolean" }, + "setup_steps": { + "type": "array", + "items": { + "type": "string" + } + }, "skip_canonical_filtering": { "type": "boolean" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 0c458a5a7d..8a559e7489 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -220,9 +220,11 @@ export type DeclarativeProviderConfig = { headers?: { [key: string]: string; } | null; + model_doc_link?: string | null; models: Array; name: string; requires_auth?: boolean; + setup_steps?: Array; skip_canonical_filtering?: boolean; supports_streaming?: boolean | null; timeout_seconds?: number | null; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index c4987814cb..f676deb140 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -178,6 +178,7 @@ export const ConfigProvider: React.FC = ({ children }) => { try { const response = await providers(); const providersData = response.data || []; + providersListRef.current = providersData; setProvidersList(providersData); return providersData; } catch (error) { @@ -199,6 +200,7 @@ export const ConfigProvider: React.FC = ({ children }) => { try { const providersResponse = await providers(); const providersData = providersResponse.data || []; + providersListRef.current = providersData; setProvidersList(providersData); } catch (error) { console.error('Failed to load providers:', error); diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 370952f58a..6ad2559c50 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -117,7 +117,7 @@ function ProviderCards({ const configureProviderViaModal = useCallback( async (provider: ProviderDetails) => { - if (provider.provider_type === 'Custom' || provider.provider_type === 'Declarative') { + if (provider.provider_type === 'Custom') { const { getCustomProvider } = await import('../../../api'); const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });