Skip to content

Commit a191f0d

Browse files
nkuhn-vmwclaude
andauthored
refactor: Convert Tanzu provider to declarative JSON config (#7124)
Signed-off-by: Nick Kuhn <nick.kuhn@broadcom.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9f061f7 commit a191f0d

File tree

9 files changed

+310
-2
lines changed

9 files changed

+310
-2
lines changed

crates/goose-server/src/openapi.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use rmcp::model::{
1717
use utoipa::{OpenApi, ToSchema};
1818

1919
use goose::config::declarative_providers::{
20-
DeclarativeProviderConfig, LoadedProvider, ProviderEngine,
20+
DeclarativeProviderConfig, EnvVarConfig, LoadedProvider, ProviderEngine,
2121
};
2222
use goose::conversation::message::{
2323
ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent,
@@ -509,6 +509,7 @@ derive_utoipa!(Icon as IconSchema);
509509
LoadedProvider,
510510
ProviderEngine,
511511
DeclarativeProviderConfig,
512+
EnvVarConfig,
512513
ExtensionEntry,
513514
ExtensionConfig,
514515
ConfigKey,

crates/goose/src/config/declarative_providers.rs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ pub enum ProviderEngine {
2727
Anthropic,
2828
}
2929

30+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
31+
pub struct EnvVarConfig {
32+
pub name: String,
33+
#[serde(default)]
34+
pub required: bool,
35+
#[serde(default)]
36+
pub secret: bool,
37+
pub description: Option<String>,
38+
pub default: Option<String>,
39+
}
40+
3041
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
3142
pub struct DeclarativeProviderConfig {
3243
pub name: String,
@@ -46,6 +57,10 @@ pub struct DeclarativeProviderConfig {
4657
pub catalog_provider_id: Option<String>,
4758
#[serde(default)]
4859
pub base_path: Option<String>,
60+
#[serde(default)]
61+
pub env_vars: Option<Vec<EnvVarConfig>>,
62+
#[serde(default)]
63+
pub dynamic_models: Option<bool>,
4964
}
5065

5166
fn default_requires_auth() -> bool {
@@ -66,6 +81,40 @@ impl DeclarativeProviderConfig {
6681
}
6782
}
6883

84+
/// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs.
85+
/// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`.
86+
/// Returns an error if a `required` var is missing.
87+
pub fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result<String> {
88+
let config = Config::global();
89+
let mut result = template.to_string();
90+
for var in env_vars {
91+
let placeholder = format!("${{{}}}", var.name);
92+
if !result.contains(&placeholder) {
93+
continue;
94+
}
95+
let value = if var.secret {
96+
config.get_secret::<String>(&var.name).ok()
97+
} else {
98+
config.get_param::<String>(&var.name).ok()
99+
};
100+
let value = match value {
101+
Some(v) => v,
102+
None => match &var.default {
103+
Some(d) => d.clone(),
104+
None if var.required => {
105+
return Err(anyhow::anyhow!(
106+
"Required environment variable {} is not set",
107+
var.name
108+
));
109+
}
110+
None => continue,
111+
},
112+
};
113+
result = result.replace(&placeholder, &value);
114+
}
115+
Ok(result)
116+
}
117+
69118
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
70119
pub struct LoadedProvider {
71120
pub config: DeclarativeProviderConfig,
@@ -164,6 +213,8 @@ pub fn create_custom_provider(
164213
requires_auth: params.requires_auth,
165214
catalog_provider_id: params.catalog_provider_id,
166215
base_path: params.base_path,
216+
env_vars: None,
217+
dynamic_models: None,
167218
};
168219

169220
let custom_providers_dir = custom_providers_dir();
@@ -227,6 +278,8 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
227278
requires_auth: params.requires_auth,
228279
catalog_provider_id: params.catalog_provider_id,
229280
base_path: params.base_path,
281+
env_vars: existing_config.env_vars,
282+
dynamic_models: existing_config.dynamic_models,
230283
};
231284

232285
let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name));
@@ -352,6 +405,13 @@ pub fn register_declarative_provider(
352405
config: DeclarativeProviderConfig,
353406
provider_type: ProviderType,
354407
) {
408+
// Expand env vars in base_url once, so individual engines don't need to
409+
let mut config = config;
410+
if let Some(ref env_vars) = config.env_vars {
411+
if let Ok(resolved) = expand_env_vars(&config.base_url, env_vars) {
412+
config.base_url = resolved;
413+
}
414+
}
355415
let config_clone = config.clone();
356416

357417
match config.engine {
@@ -378,3 +438,133 @@ pub fn register_declarative_provider(
378438
}
379439
}
380440
}
441+
442+
#[cfg(test)]
443+
mod tests {
444+
use super::*;
445+
446+
#[test]
447+
fn test_tanzu_json_deserializes() {
448+
let json = include_str!("../providers/declarative/tanzu.json");
449+
let config: DeclarativeProviderConfig =
450+
serde_json::from_str(json).expect("tanzu.json should parse");
451+
assert_eq!(config.name, "tanzu_ai");
452+
assert_eq!(config.display_name, "Tanzu AI Services");
453+
assert!(matches!(config.engine, ProviderEngine::OpenAI));
454+
assert_eq!(config.api_key_env, "TANZU_AI_API_KEY");
455+
assert_eq!(
456+
config.base_url,
457+
"${TANZU_AI_ENDPOINT}/openai/v1/chat/completions"
458+
);
459+
assert_eq!(config.dynamic_models, Some(true));
460+
assert_eq!(config.supports_streaming, Some(false));
461+
462+
let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
463+
assert_eq!(env_vars.len(), 1);
464+
assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT");
465+
assert!(env_vars[0].required);
466+
assert!(!env_vars[0].secret);
467+
468+
assert_eq!(config.models.len(), 1);
469+
assert_eq!(config.models[0].name, "openai/gpt-oss-120b");
470+
}
471+
472+
#[test]
473+
fn test_existing_json_files_still_deserialize_without_new_fields() {
474+
let json = include_str!("../providers/declarative/groq.json");
475+
let config: DeclarativeProviderConfig =
476+
serde_json::from_str(json).expect("groq.json should parse without env_vars");
477+
assert!(config.env_vars.is_none());
478+
assert!(config.dynamic_models.is_none());
479+
}
480+
481+
#[test]
482+
fn test_expand_env_vars_replaces_placeholder() {
483+
let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]);
484+
485+
let env_vars = vec![EnvVarConfig {
486+
name: "TEST_EXPAND_HOST".to_string(),
487+
required: true,
488+
secret: false,
489+
description: None,
490+
default: None,
491+
}];
492+
493+
let result = expand_env_vars("${TEST_EXPAND_HOST}/v1/chat/completions", &env_vars).unwrap();
494+
assert_eq!(result, "https://example.com/api/v1/chat/completions");
495+
}
496+
497+
#[test]
498+
fn test_expand_env_vars_required_missing_errors() {
499+
let _guard = env_lock::lock_env([("TEST_EXPAND_MISSING", None::<&str>)]);
500+
501+
let env_vars = vec![EnvVarConfig {
502+
name: "TEST_EXPAND_MISSING".to_string(),
503+
required: true,
504+
secret: false,
505+
description: None,
506+
default: None,
507+
}];
508+
509+
let result = expand_env_vars("${TEST_EXPAND_MISSING}/path", &env_vars);
510+
assert!(result.is_err());
511+
assert!(result
512+
.unwrap_err()
513+
.to_string()
514+
.contains("TEST_EXPAND_MISSING"));
515+
}
516+
517+
#[test]
518+
fn test_expand_env_vars_uses_default_when_missing() {
519+
let _guard = env_lock::lock_env([("TEST_EXPAND_DEFAULT", None::<&str>)]);
520+
521+
let env_vars = vec![EnvVarConfig {
522+
name: "TEST_EXPAND_DEFAULT".to_string(),
523+
required: false,
524+
secret: false,
525+
description: None,
526+
default: Some("https://fallback.example.com".to_string()),
527+
}];
528+
529+
let result =
530+
expand_env_vars("${TEST_EXPAND_DEFAULT}/v1/chat/completions", &env_vars).unwrap();
531+
assert_eq!(result, "https://fallback.example.com/v1/chat/completions");
532+
}
533+
534+
#[test]
535+
fn test_expand_env_vars_no_placeholders_passthrough() {
536+
let env_vars = vec![EnvVarConfig {
537+
name: "UNUSED_VAR".to_string(),
538+
required: true,
539+
secret: false,
540+
description: None,
541+
default: None,
542+
}];
543+
544+
let result =
545+
expand_env_vars("https://static.example.com/v1/chat/completions", &env_vars).unwrap();
546+
assert_eq!(result, "https://static.example.com/v1/chat/completions");
547+
}
548+
549+
#[test]
550+
fn test_expand_env_vars_empty_slice_passthrough() {
551+
let result = expand_env_vars("${WHATEVER}/path", &[]).unwrap();
552+
assert_eq!(result, "${WHATEVER}/path");
553+
}
554+
555+
#[test]
556+
fn test_expand_env_vars_env_value_overrides_default() {
557+
let _guard = env_lock::lock_env([("TEST_EXPAND_OVERRIDE", Some("https://from-env.com"))]);
558+
559+
let env_vars = vec![EnvVarConfig {
560+
name: "TEST_EXPAND_OVERRIDE".to_string(),
561+
required: false,
562+
secret: false,
563+
description: None,
564+
default: Some("https://from-default.com".to_string()),
565+
}];
566+
567+
let result = expand_env_vars("${TEST_EXPAND_OVERRIDE}/path", &env_vars).unwrap();
568+
assert_eq!(result, "https://from-env.com/path");
569+
}
570+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "tanzu_ai",
3+
"engine": "openai",
4+
"display_name": "Tanzu AI Services",
5+
"description": "Enterprise-managed LLM access through VMware Tanzu Platform AI Services",
6+
"api_key_env": "TANZU_AI_API_KEY",
7+
"base_url": "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions",
8+
"env_vars": [
9+
{
10+
"name": "TANZU_AI_ENDPOINT",
11+
"required": true,
12+
"secret": false,
13+
"description": "Your Tanzu AI Services endpoint URL"
14+
}
15+
],
16+
"dynamic_models": true,
17+
"models": [
18+
{ "name": "openai/gpt-oss-120b", "context_limit": 131072 }
19+
],
20+
"supports_streaming": false
21+
}

crates/goose/src/providers/init.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,43 @@ mod tests {
322322
assert_eq!(result.context_limit, Some(expected_limit));
323323
}
324324

325+
#[tokio::test]
326+
async fn test_tanzu_declarative_provider_registry_wiring() {
327+
let providers_list = providers().await;
328+
let tanzu = providers_list
329+
.iter()
330+
.find(|(m, _)| m.name == "tanzu_ai")
331+
.expect("tanzu_ai provider should be registered");
332+
let (meta, provider_type) = tanzu;
333+
334+
// Should be a Declarative (fixed) provider
335+
assert_eq!(*provider_type, ProviderType::Declarative);
336+
337+
assert_eq!(meta.display_name, "Tanzu AI Services");
338+
assert_eq!(meta.default_model, "openai/gpt-oss-120b");
339+
340+
// First config key should be TANZU_AI_API_KEY (secret, required)
341+
let api_key = meta
342+
.config_keys
343+
.iter()
344+
.find(|k| k.name == "TANZU_AI_API_KEY")
345+
.expect("TANZU_AI_API_KEY config key should exist");
346+
assert!(
347+
api_key.required,
348+
"API key should be required for fixed declarative provider"
349+
);
350+
assert!(api_key.secret, "API key should be secret");
351+
352+
// Should have TANZU_AI_ENDPOINT config key (not secret, required)
353+
let endpoint = meta
354+
.config_keys
355+
.iter()
356+
.find(|k| k.name == "TANZU_AI_ENDPOINT")
357+
.expect("TANZU_AI_ENDPOINT config key should exist");
358+
assert!(endpoint.required, "Endpoint should be required");
359+
assert!(!endpoint.secret, "Endpoint should not be secret");
360+
}
361+
325362
#[tokio::test]
326363
async fn test_openai_compatible_providers_config_keys() {
327364
let providers_list = providers().await;

crates/goose/src/providers/provider_registry.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ impl ProviderRegistry {
119119
}
120120
}
121121

122+
if let Some(ref env_vars) = config.env_vars {
123+
for ev in env_vars {
124+
config_keys.push(super::base::ConfigKey::new(
125+
&ev.name,
126+
ev.required,
127+
ev.secret,
128+
ev.default.as_deref(),
129+
false,
130+
));
131+
}
132+
}
133+
122134
let custom_metadata = ProviderMetadata {
123135
name: config.name.clone(),
124136
display_name: config.display_name.clone(),

documentation/docs/getting-started/providers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a
4343
| [OVHcloud AI](https://www.ovhcloud.com/en/public-cloud/ai-endpoints/) | Provides access to open-source models including Qwen, Llama, Mistral, and DeepSeek through AI Endpoints service. | `OVHCLOUD_API_KEY` |
4444
| [Ramalama](https://ramalama.ai/) | Local model using native [OCI](https://opencontainers.org/) container runtimes, [CNCF](https://www.cncf.io/) tools, and supporting models as OCI artifacts. Ramalama API is a compatible alternative to Ollama and can be used with the goose Ollama provider. Supports Qwen, Llama, DeepSeek, and other open-source models. **Because this provider runs locally, you must first [download and run a model](#local-llms).** | `OLLAMA_HOST` |
4545
| [Snowflake](https://docs.snowflake.com/user-guide/snowflake-cortex/aisql#choosing-a-model) | Access the latest models using Snowflake Cortex services, including Claude models. **Requires a Snowflake account and programmatic access token (PAT)**. | `SNOWFLAKE_HOST`, `SNOWFLAKE_TOKEN` |
46+
| [Tanzu AI Services](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform/ai-services/10-3/ai/index.html) | Enterprise-managed LLM access through VMware Tanzu Platform AI Services. Models are fetched dynamically from the endpoint. | `TANZU_AI_API_KEY`, `TANZU_AI_ENDPOINT` |
4647
| [Tetrate Agent Router Service](https://router.tetrate.ai) | Unified API gateway for AI models including Claude, Gemini, GPT, open-weight models, and others. Supports PKCE authentication flow for secure API key generation. | `TETRATE_API_KEY`, `TETRATE_HOST` (optional) |
4748
| [Venice AI](https://venice.ai/home) | Provides access to open source models like Llama, Mistral, and Qwen while prioritizing user privacy. **Requires an account and an [API key](https://docs.venice.ai/overview/guides/generating-api-key)**. | `VENICE_API_KEY`, `VENICE_HOST` (optional), `VENICE_BASE_PATH` (optional), `VENICE_MODELS_PATH` (optional) |
4849
| [xAI](https://x.ai/) | Access to xAI's Grok models including grok-3, grok-3-mini, and grok-3-fast with 131,072 token context window. | `XAI_API_KEY`, `XAI_HOST` (optional) |

ui/desktop/openapi.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4195,9 +4195,20 @@
41954195
"display_name": {
41964196
"type": "string"
41974197
},
4198+
"dynamic_models": {
4199+
"type": "boolean",
4200+
"nullable": true
4201+
},
41984202
"engine": {
41994203
"$ref": "#/components/schemas/ProviderEngine"
42004204
},
4205+
"env_vars": {
4206+
"type": "array",
4207+
"items": {
4208+
"$ref": "#/components/schemas/EnvVarConfig"
4209+
},
4210+
"nullable": true
4211+
},
42014212
"headers": {
42024213
"type": "object",
42034214
"additionalProperties": {
@@ -4460,6 +4471,31 @@
44604471
}
44614472
}
44624473
},
4474+
"EnvVarConfig": {
4475+
"type": "object",
4476+
"required": [
4477+
"name"
4478+
],
4479+
"properties": {
4480+
"default": {
4481+
"type": "string",
4482+
"nullable": true
4483+
},
4484+
"description": {
4485+
"type": "string",
4486+
"nullable": true
4487+
},
4488+
"name": {
4489+
"type": "string"
4490+
},
4491+
"required": {
4492+
"type": "boolean"
4493+
},
4494+
"secret": {
4495+
"type": "boolean"
4496+
}
4497+
}
4498+
},
44634499
"Envs": {
44644500
"type": "object",
44654501
"additionalProperties": {

0 commit comments

Comments
 (0)