Skip to content

Commit c936514

Browse files
nkuhn-vmwclaudeDouwe Osinga
authored
fix: VMware Tanzu Platform provider - bug fixes, streaming, UI improvements (#8126)
Signed-off-by: Nick Kuhn <nick.kuhn@broadcom.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
1 parent cdf91ea commit c936514

16 files changed

Lines changed: 723 additions & 59 deletions

File tree

crates/goose/src/config/declarative_providers.rs

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ pub struct EnvVarConfig {
3434
pub required: bool,
3535
#[serde(default)]
3636
pub secret: bool,
37+
/// When true, the field is shown prominently in the UI (not collapsed).
38+
/// Defaults to the value of `required` if not specified.
39+
pub primary: Option<bool>,
3740
pub description: Option<String>,
3841
pub default: Option<String>,
3942
}
@@ -404,40 +407,78 @@ pub fn register_declarative_providers(
404407
Ok(())
405408
}
406409

410+
/// Resolve `${VAR}` placeholders in the config's `base_url` and apply
411+
/// runtime overrides from env_vars. Called lazily (at provider instantiation)
412+
/// so values configured through the UI after startup are picked up.
413+
fn resolve_config(config: &mut DeclarativeProviderConfig) -> Result<()> {
414+
if let Some(ref env_vars) = config.env_vars {
415+
config.base_url = expand_env_vars(&config.base_url, env_vars)?;
416+
417+
// Check for streaming override via env_vars.
418+
// Config/env may store the value as a string ("true") or a native bool,
419+
// so try String first, then fall back to bool.
420+
let global_config = Config::global();
421+
for var in env_vars {
422+
if var.name.ends_with("_STREAMING") {
423+
let val: Option<bool> = global_config
424+
.get_param::<String>(&var.name)
425+
.ok()
426+
.map(|s| s.to_lowercase() == "true")
427+
.or_else(|| global_config.get_param::<bool>(&var.name).ok())
428+
.or_else(|| var.default.as_deref().map(|d| d.to_lowercase() == "true"));
429+
if let Some(v) = val {
430+
config.supports_streaming = Some(v);
431+
}
432+
}
433+
}
434+
}
435+
Ok(())
436+
}
437+
407438
pub fn register_declarative_provider(
408439
registry: &mut crate::providers::provider_registry::ProviderRegistry,
409440
config: DeclarativeProviderConfig,
410441
provider_type: ProviderType,
411442
) {
412-
// Expand env vars in base_url once, so individual engines don't need to
413-
let mut config = config;
414-
if let Some(ref env_vars) = config.env_vars {
415-
if let Ok(resolved) = expand_env_vars(&config.base_url, env_vars) {
416-
config.base_url = resolved;
417-
}
418-
}
419-
let config_clone = config.clone();
420-
443+
// Each closure needs its own owned copy of config because closures are
444+
// moved into the registry and may be invoked much later than registration.
445+
// Env var expansion happens lazily inside resolve_base_url so that values
446+
// configured through the UI after startup are picked up.
421447
match config.engine {
422448
ProviderEngine::OpenAI => {
449+
let captured = config.clone();
423450
registry.register_with_name::<OpenAiProvider, _>(
424451
&config,
425452
provider_type,
426-
move |model| OpenAiProvider::from_custom_config(model, config_clone.clone()),
453+
move |model| {
454+
let mut cfg = captured.clone();
455+
resolve_config(&mut cfg)?;
456+
OpenAiProvider::from_custom_config(model, cfg)
457+
},
427458
);
428459
}
429460
ProviderEngine::Ollama => {
461+
let captured = config.clone();
430462
registry.register_with_name::<OllamaProvider, _>(
431463
&config,
432464
provider_type,
433-
move |model| OllamaProvider::from_custom_config(model, config_clone.clone()),
465+
move |model| {
466+
let mut cfg = captured.clone();
467+
resolve_config(&mut cfg)?;
468+
OllamaProvider::from_custom_config(model, cfg)
469+
},
434470
);
435471
}
436472
ProviderEngine::Anthropic => {
473+
let captured = config.clone();
437474
registry.register_with_name::<AnthropicProvider, _>(
438475
&config,
439476
provider_type,
440-
move |model| AnthropicProvider::from_custom_config(model, config_clone.clone()),
477+
move |model| {
478+
let mut cfg = captured.clone();
479+
resolve_config(&mut cfg)?;
480+
AnthropicProvider::from_custom_config(model, cfg)
481+
},
441482
);
442483
}
443484
}
@@ -453,21 +494,24 @@ mod tests {
453494
let config: DeclarativeProviderConfig =
454495
serde_json::from_str(json).expect("tanzu.json should parse");
455496
assert_eq!(config.name, "tanzu_ai");
456-
assert_eq!(config.display_name, "Tanzu AI Services");
497+
assert_eq!(config.display_name, "VMware Tanzu Platform");
457498
assert!(matches!(config.engine, ProviderEngine::OpenAI));
458499
assert_eq!(config.api_key_env, "TANZU_AI_API_KEY");
459500
assert_eq!(
460501
config.base_url,
461502
"${TANZU_AI_ENDPOINT}/openai/v1/chat/completions"
462503
);
463504
assert_eq!(config.dynamic_models, Some(true));
464-
assert_eq!(config.supports_streaming, Some(false));
505+
assert_eq!(config.supports_streaming, Some(true));
465506

466507
let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
467-
assert_eq!(env_vars.len(), 1);
508+
assert_eq!(env_vars.len(), 2);
468509
assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT");
469510
assert!(env_vars[0].required);
470511
assert!(!env_vars[0].secret);
512+
assert_eq!(env_vars[1].name, "TANZU_AI_STREAMING");
513+
assert!(!env_vars[1].required);
514+
assert_eq!(env_vars[1].default, Some("true".to_string()));
471515

472516
assert_eq!(config.models.len(), 1);
473517
assert_eq!(config.models[0].name, "openai/gpt-oss-120b");
@@ -490,6 +534,7 @@ mod tests {
490534
name: "TEST_EXPAND_HOST".to_string(),
491535
required: true,
492536
secret: false,
537+
primary: None,
493538
description: None,
494539
default: None,
495540
}];
@@ -506,6 +551,7 @@ mod tests {
506551
name: "TEST_EXPAND_MISSING".to_string(),
507552
required: true,
508553
secret: false,
554+
primary: None,
509555
description: None,
510556
default: None,
511557
}];
@@ -526,6 +572,7 @@ mod tests {
526572
name: "TEST_EXPAND_DEFAULT".to_string(),
527573
required: false,
528574
secret: false,
575+
primary: None,
529576
description: None,
530577
default: Some("https://fallback.example.com".to_string()),
531578
}];
@@ -541,6 +588,7 @@ mod tests {
541588
name: "UNUSED_VAR".to_string(),
542589
required: true,
543590
secret: false,
591+
primary: None,
544592
description: None,
545593
default: None,
546594
}];
@@ -564,6 +612,7 @@ mod tests {
564612
name: "TEST_EXPAND_OVERRIDE".to_string(),
565613
required: false,
566614
secret: false,
615+
primary: None,
567616
description: None,
568617
default: Some("https://from-default.com".to_string()),
569618
}];
Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
{
22
"name": "tanzu_ai",
33
"engine": "openai",
4-
"display_name": "Tanzu AI Services",
5-
"description": "Enterprise-managed LLM access through VMware Tanzu Platform AI Services",
4+
"display_name": "VMware Tanzu Platform",
5+
"description": "Enterprise-managed LLM access through AI Services on VMware Tanzu Platform.",
66
"api_key_env": "TANZU_AI_API_KEY",
77
"base_url": "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions",
88
"env_vars": [
99
{
1010
"name": "TANZU_AI_ENDPOINT",
1111
"required": true,
1212
"secret": false,
13-
"description": "Your Tanzu AI Services endpoint URL"
13+
"description": "Your VMware Tanzu Platform AI Services endpoint URL"
14+
},
15+
{
16+
"name": "TANZU_AI_STREAMING",
17+
"required": false,
18+
"secret": false,
19+
"primary": true,
20+
"default": "true",
21+
"description": "Enable streaming responses (true/false)"
1422
}
1523
],
1624
"dynamic_models": true,
1725
"models": [
1826
{ "name": "openai/gpt-oss-120b", "context_limit": 131072 }
1927
],
20-
"supports_streaming": false
28+
"supports_streaming": true
2129
}

crates/goose/src/providers/init.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ mod tests {
191191
// Should be a Declarative (fixed) provider
192192
assert_eq!(*provider_type, ProviderType::Declarative);
193193

194-
assert_eq!(meta.display_name, "Tanzu AI Services");
194+
assert_eq!(meta.display_name, "VMware Tanzu Platform");
195195
assert_eq!(meta.default_model, "openai/gpt-oss-120b");
196196

197197
// First config key should be TANZU_AI_API_KEY (secret, required)

crates/goose/src/providers/openai.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,17 @@ impl OpenAiProvider {
153153
let global_config = crate::config::Config::global();
154154

155155
let api_key: Option<String> = if config.requires_auth && !config.api_key_env.is_empty() {
156-
global_config.get_secret(&config.api_key_env).ok()
156+
Some(global_config.get_secret::<String>(&config.api_key_env).map_err(|e| {
157+
use crate::config::ConfigError;
158+
match e {
159+
ConfigError::NotFound(_) => anyhow::anyhow!(
160+
"Required API key {} is not set. Configure it via `goose configure` or set the {} environment variable.",
161+
config.api_key_env,
162+
config.api_key_env
163+
),
164+
other => anyhow::anyhow!("Failed to read {}: {}", config.api_key_env, other),
165+
}
166+
})?)
157167
} else {
158168
None
159169
};

crates/goose/src/providers/provider_registry.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,14 @@ impl ProviderRegistry {
125125

126126
if let Some(ref env_vars) = config.env_vars {
127127
for ev in env_vars {
128+
// Default primary to `required` so required fields show prominently in the UI
129+
let primary = ev.primary.unwrap_or(ev.required);
128130
config_keys.push(super::base::ConfigKey::new(
129131
&ev.name,
130132
ev.required,
131133
ev.secret,
132134
ev.default.as_deref(),
133-
false,
135+
primary,
134136
));
135137
}
136138
}

documentation/docs/getting-started/providers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +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` |
46+
| [VMware Tanzu Platform](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform/ai-services/10-3/ai/index.html) | Enterprise-managed LLM access through AI Services on VMware Tanzu Platform. Models are fetched dynamically from the endpoint. | `TANZU_AI_API_KEY`, `TANZU_AI_ENDPOINT` |
4747
| [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) |
4848
| [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) |
4949
| [Cerebras](https://cerebras.ai/) | Fast inference on Cerebras wafer-scale engines with models like Llama, Qwen, and others. | `CEREBRAS_API_KEY` |

0 commit comments

Comments
 (0)