Skip to content

Commit 9071c58

Browse files
nkuhn-vmwclaude
andcommitted
refactor: Convert Tanzu provider to declarative JSON config
Replace the dedicated ~1,000-line Rust Tanzu provider with a 21-line declarative JSON config. Add generic env_vars and dynamic_models support to the declarative provider framework so all providers benefit. - Add EnvVarConfig struct and expand_env_vars() helper for ${VAR} substitution in declarative provider base_url templates - Add dynamic_models field to enable allows_unlisted_models in registry - Wire env var expansion into OpenAI, Ollama, and Anthropic engines' from_custom_config() methods - Create declarative/tanzu.json with env var-driven endpoint config - Delete dedicated tanzu.rs provider (562 lines) and tests (449 lines) - Fix latent bug in ollama.rs port check using raw template vs resolved URL - Add 9 new tests (deserialization, env var expansion, registry wiring) Net: +265 -1,027 lines. 582 tests pass, clippy clean. Manually verified end-to-end against live Tanzu AI Services endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Nick Kuhn <nick.kuhn@broadcom.com>
1 parent a196b7b commit 9071c58

File tree

13 files changed

+324
-1028
lines changed

13 files changed

+324
-1028
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,
@@ -490,6 +490,7 @@ derive_utoipa!(Icon as IconSchema);
490490
LoadedProvider,
491491
ProviderEngine,
492492
DeclarativeProviderConfig,
493+
EnvVarConfig,
493494
ExtensionEntry,
494495
ExtensionConfig,
495496
ConfigKey,

crates/goose/src/config/declarative_providers.rs

Lines changed: 183 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,
@@ -42,6 +53,10 @@ pub struct DeclarativeProviderConfig {
4253
pub supports_streaming: Option<bool>,
4354
#[serde(default = "default_requires_auth")]
4455
pub requires_auth: bool,
56+
#[serde(default)]
57+
pub env_vars: Option<Vec<EnvVarConfig>>,
58+
#[serde(default)]
59+
pub dynamic_models: Option<bool>,
4560
}
4661

4762
fn default_requires_auth() -> bool {
@@ -62,6 +77,40 @@ impl DeclarativeProviderConfig {
6277
}
6378
}
6479

80+
/// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs.
81+
/// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`.
82+
/// Returns an error if a `required` var is missing.
83+
pub fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result<String> {
84+
let config = Config::global();
85+
let mut result = template.to_string();
86+
for var in env_vars {
87+
let placeholder = format!("${{{}}}", var.name);
88+
if !result.contains(&placeholder) {
89+
continue;
90+
}
91+
let value = if var.secret {
92+
config.get_secret::<String>(&var.name).ok()
93+
} else {
94+
config.get_param::<String>(&var.name).ok()
95+
};
96+
let value = match value {
97+
Some(v) => v,
98+
None => match &var.default {
99+
Some(d) => d.clone(),
100+
None if var.required => {
101+
return Err(anyhow::anyhow!(
102+
"Required environment variable {} is not set",
103+
var.name
104+
));
105+
}
106+
None => continue,
107+
},
108+
};
109+
result = result.replace(&placeholder, &value);
110+
}
111+
Ok(result)
112+
}
113+
65114
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
66115
pub struct LoadedProvider {
67116
pub config: DeclarativeProviderConfig,
@@ -154,6 +203,8 @@ pub fn create_custom_provider(
154203
timeout_seconds: None,
155204
supports_streaming: params.supports_streaming,
156205
requires_auth: params.requires_auth,
206+
env_vars: None,
207+
dynamic_models: None,
157208
};
158209

159210
let custom_providers_dir = custom_providers_dir();
@@ -211,6 +262,8 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
211262
timeout_seconds: existing_config.timeout_seconds,
212263
supports_streaming: params.supports_streaming,
213264
requires_auth: params.requires_auth,
265+
env_vars: existing_config.env_vars,
266+
dynamic_models: existing_config.dynamic_models,
214267
};
215268

216269
let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name));
@@ -351,3 +404,133 @@ pub fn register_declarative_provider(
351404
}
352405
}
353406
}
407+
408+
#[cfg(test)]
409+
mod tests {
410+
use super::*;
411+
412+
#[test]
413+
fn test_tanzu_json_deserializes() {
414+
let json = include_str!("../providers/declarative/tanzu.json");
415+
let config: DeclarativeProviderConfig =
416+
serde_json::from_str(json).expect("tanzu.json should parse");
417+
assert_eq!(config.name, "tanzu_ai");
418+
assert_eq!(config.display_name, "Tanzu AI Services");
419+
assert!(matches!(config.engine, ProviderEngine::OpenAI));
420+
assert_eq!(config.api_key_env, "TANZU_AI_API_KEY");
421+
assert_eq!(
422+
config.base_url,
423+
"${TANZU_AI_ENDPOINT}/openai/v1/chat/completions"
424+
);
425+
assert_eq!(config.dynamic_models, Some(true));
426+
assert_eq!(config.supports_streaming, Some(false));
427+
428+
let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
429+
assert_eq!(env_vars.len(), 1);
430+
assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT");
431+
assert!(env_vars[0].required);
432+
assert!(!env_vars[0].secret);
433+
434+
assert_eq!(config.models.len(), 1);
435+
assert_eq!(config.models[0].name, "openai/gpt-oss-120b");
436+
}
437+
438+
#[test]
439+
fn test_existing_json_files_still_deserialize_without_new_fields() {
440+
let json = include_str!("../providers/declarative/groq.json");
441+
let config: DeclarativeProviderConfig =
442+
serde_json::from_str(json).expect("groq.json should parse without env_vars");
443+
assert!(config.env_vars.is_none());
444+
assert!(config.dynamic_models.is_none());
445+
}
446+
447+
#[test]
448+
fn test_expand_env_vars_replaces_placeholder() {
449+
let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]);
450+
451+
let env_vars = vec![EnvVarConfig {
452+
name: "TEST_EXPAND_HOST".to_string(),
453+
required: true,
454+
secret: false,
455+
description: None,
456+
default: None,
457+
}];
458+
459+
let result = expand_env_vars("${TEST_EXPAND_HOST}/v1/chat/completions", &env_vars).unwrap();
460+
assert_eq!(result, "https://example.com/api/v1/chat/completions");
461+
}
462+
463+
#[test]
464+
fn test_expand_env_vars_required_missing_errors() {
465+
let _guard = env_lock::lock_env([("TEST_EXPAND_MISSING", None::<&str>)]);
466+
467+
let env_vars = vec![EnvVarConfig {
468+
name: "TEST_EXPAND_MISSING".to_string(),
469+
required: true,
470+
secret: false,
471+
description: None,
472+
default: None,
473+
}];
474+
475+
let result = expand_env_vars("${TEST_EXPAND_MISSING}/path", &env_vars);
476+
assert!(result.is_err());
477+
assert!(result
478+
.unwrap_err()
479+
.to_string()
480+
.contains("TEST_EXPAND_MISSING"));
481+
}
482+
483+
#[test]
484+
fn test_expand_env_vars_uses_default_when_missing() {
485+
let _guard = env_lock::lock_env([("TEST_EXPAND_DEFAULT", None::<&str>)]);
486+
487+
let env_vars = vec![EnvVarConfig {
488+
name: "TEST_EXPAND_DEFAULT".to_string(),
489+
required: false,
490+
secret: false,
491+
description: None,
492+
default: Some("https://fallback.example.com".to_string()),
493+
}];
494+
495+
let result =
496+
expand_env_vars("${TEST_EXPAND_DEFAULT}/v1/chat/completions", &env_vars).unwrap();
497+
assert_eq!(result, "https://fallback.example.com/v1/chat/completions");
498+
}
499+
500+
#[test]
501+
fn test_expand_env_vars_no_placeholders_passthrough() {
502+
let env_vars = vec![EnvVarConfig {
503+
name: "UNUSED_VAR".to_string(),
504+
required: true,
505+
secret: false,
506+
description: None,
507+
default: None,
508+
}];
509+
510+
let result =
511+
expand_env_vars("https://static.example.com/v1/chat/completions", &env_vars).unwrap();
512+
assert_eq!(result, "https://static.example.com/v1/chat/completions");
513+
}
514+
515+
#[test]
516+
fn test_expand_env_vars_empty_slice_passthrough() {
517+
let result = expand_env_vars("${WHATEVER}/path", &[]).unwrap();
518+
assert_eq!(result, "${WHATEVER}/path");
519+
}
520+
521+
#[test]
522+
fn test_expand_env_vars_env_value_overrides_default() {
523+
let _guard = env_lock::lock_env([("TEST_EXPAND_OVERRIDE", Some("https://from-env.com"))]);
524+
525+
let env_vars = vec![EnvVarConfig {
526+
name: "TEST_EXPAND_OVERRIDE".to_string(),
527+
required: false,
528+
secret: false,
529+
description: None,
530+
default: Some("https://from-default.com".to_string()),
531+
}];
532+
533+
let result = expand_env_vars("${TEST_EXPAND_OVERRIDE}/path", &env_vars).unwrap();
534+
assert_eq!(result, "https://from-env.com/path");
535+
}
536+
}

crates/goose/src/providers/anthropic.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,13 @@ impl AnthropicProvider {
9797
key: api_key,
9898
};
9999

100-
let mut api_client = ApiClient::new(config.base_url, auth)?
100+
let resolved_url = if let Some(ref env_vars) = config.env_vars {
101+
crate::config::declarative_providers::expand_env_vars(&config.base_url, env_vars)?
102+
} else {
103+
config.base_url.clone()
104+
};
105+
106+
let mut api_client = ApiClient::new(resolved_url, auth)?
101107
.with_header("anthropic-version", ANTHROPIC_API_VERSION)?;
102108

103109
if let Some(headers) = &config.headers {
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: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ use super::{
2222
provider_registry::ProviderRegistry,
2323
sagemaker_tgi::SageMakerTgiProvider,
2424
snowflake::SnowflakeProvider,
25-
tanzu::TanzuAIServicesProvider,
2625
tetrate::TetrateProvider,
2726
venice::VeniceProvider,
2827
xai::XaiProvider,
@@ -62,7 +61,6 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
6261
registry.register::<OpenRouterProvider>(true);
6362
registry.register::<SageMakerTgiProvider>(false);
6463
registry.register::<SnowflakeProvider>(false);
65-
registry.register::<TanzuAIServicesProvider>(false);
6664
registry.register::<TetrateProvider>(true);
6765
registry.register::<VeniceProvider>(false);
6866
registry.register::<XaiProvider>(false);
@@ -296,6 +294,47 @@ mod tests {
296294
assert_eq!(result.context_limit, Some(expected_limit));
297295
}
298296

297+
#[tokio::test]
298+
async fn test_tanzu_declarative_provider_registry_wiring() {
299+
let providers_list = providers().await;
300+
let tanzu = providers_list
301+
.iter()
302+
.find(|(m, _)| m.name == "tanzu_ai")
303+
.expect("tanzu_ai provider should be registered");
304+
let (meta, provider_type) = tanzu;
305+
306+
// Should be a Declarative (fixed) provider
307+
assert_eq!(*provider_type, ProviderType::Declarative);
308+
309+
assert_eq!(meta.display_name, "Tanzu AI Services");
310+
assert_eq!(meta.default_model, "openai/gpt-oss-120b");
311+
assert!(
312+
meta.allows_unlisted_models,
313+
"dynamic_models should enable allows_unlisted_models"
314+
);
315+
316+
// First config key should be TANZU_AI_API_KEY (secret, required)
317+
let api_key = meta
318+
.config_keys
319+
.iter()
320+
.find(|k| k.name == "TANZU_AI_API_KEY")
321+
.expect("TANZU_AI_API_KEY config key should exist");
322+
assert!(
323+
api_key.required,
324+
"API key should be required for fixed declarative provider"
325+
);
326+
assert!(api_key.secret, "API key should be secret");
327+
328+
// Should have TANZU_AI_ENDPOINT config key (not secret, required)
329+
let endpoint = meta
330+
.config_keys
331+
.iter()
332+
.find(|k| k.name == "TANZU_AI_ENDPOINT")
333+
.expect("TANZU_AI_ENDPOINT config key should exist");
334+
assert!(endpoint.required, "Endpoint should be required");
335+
assert!(!endpoint.secret, "Endpoint should not be secret");
336+
}
337+
299338
#[tokio::test]
300339
async fn test_openai_compatible_providers_config_keys() {
301340
let providers_list = providers().await;

crates/goose/src/providers/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ pub mod provider_test;
3232
mod retry;
3333
pub mod sagemaker_tgi;
3434
pub mod snowflake;
35-
pub mod tanzu;
3635
pub mod testprovider;
3736
pub mod tetrate;
3837
pub mod toolshim;

crates/goose/src/providers/ollama.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,22 @@ impl OllamaProvider {
9999
) -> Result<Self> {
100100
let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT));
101101

102-
let base =
103-
if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") {
104-
config.base_url.clone()
105-
} else {
106-
format!("http://{}", config.base_url)
107-
};
102+
let resolved_url = if let Some(ref env_vars) = config.env_vars {
103+
crate::config::declarative_providers::expand_env_vars(&config.base_url, env_vars)?
104+
} else {
105+
config.base_url.clone()
106+
};
107+
108+
let base = if resolved_url.starts_with("http://") || resolved_url.starts_with("https://") {
109+
resolved_url.clone()
110+
} else {
111+
format!("http://{}", resolved_url)
112+
};
108113

109114
let mut base_url = Url::parse(&base)
110-
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?;
115+
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", resolved_url, e))?;
111116

112-
let explicit_default_port =
113-
config.base_url.ends_with(":80") || config.base_url.ends_with(":443");
117+
let explicit_default_port = resolved_url.ends_with(":80") || resolved_url.ends_with(":443");
114118
let is_https = base_url.scheme() == "https";
115119

116120
if base_url.port().is_none() && !explicit_default_port && !is_https {

crates/goose/src/providers/openai.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,14 @@ impl OpenAiProvider {
155155
None
156156
};
157157

158-
let url = url::Url::parse(&config.base_url)
159-
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?;
158+
let resolved_url = if let Some(ref env_vars) = config.env_vars {
159+
crate::config::declarative_providers::expand_env_vars(&config.base_url, env_vars)?
160+
} else {
161+
config.base_url.clone()
162+
};
163+
164+
let url = url::Url::parse(&resolved_url)
165+
.map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", resolved_url, e))?;
160166

161167
let host = if let Some(port) = url.port() {
162168
format!(

0 commit comments

Comments
 (0)