Add NVIDIA provider, and improve declarative provider UX (#8798)

Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
jh-block
2026-04-24 15:48:30 +02:00
committed by GitHub
parent 8f16ec61e4
commit 4065d44ef7
9 changed files with 154 additions and 21 deletions
@@ -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<String>,
#[serde(default)]
pub setup_steps: Vec<String>,
#[serde(default, deserialize_with = "deserialize_non_empty_string")]
pub fast_model: Option<String>,
}
@@ -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]
+19 -1
View File
@@ -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([
@@ -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"
]
}
+35
View File
@@ -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;
+26 -19
View File
@@ -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();
+10
View File
@@ -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"
},
+2
View File
@@ -220,9 +220,11 @@ export type DeclarativeProviderConfig = {
headers?: {
[key: string]: string;
} | null;
model_doc_link?: string | null;
models: Array<ModelInfo>;
name: string;
requires_auth?: boolean;
setup_steps?: Array<string>;
skip_canonical_filtering?: boolean;
supports_streaming?: boolean | null;
timeout_seconds?: number | null;
@@ -178,6 +178,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ children }) => {
try {
const providersResponse = await providers();
const providersData = providersResponse.data || [];
providersListRef.current = providersData;
setProvidersList(providersData);
} catch (error) {
console.error('Failed to load providers:', error);
@@ -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 });