mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
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>
This commit is contained in:
@@ -34,6 +34,9 @@ pub struct EnvVarConfig {
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub secret: bool,
|
||||
/// When true, the field is shown prominently in the UI (not collapsed).
|
||||
/// Defaults to the value of `required` if not specified.
|
||||
pub primary: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
pub default: Option<String>,
|
||||
}
|
||||
@@ -404,40 +407,78 @@ pub fn register_declarative_providers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve `${VAR}` placeholders in the config's `base_url` and apply
|
||||
/// runtime overrides from env_vars. Called lazily (at provider instantiation)
|
||||
/// so values configured through the UI after startup are picked up.
|
||||
fn resolve_config(config: &mut DeclarativeProviderConfig) -> Result<()> {
|
||||
if let Some(ref env_vars) = config.env_vars {
|
||||
config.base_url = expand_env_vars(&config.base_url, env_vars)?;
|
||||
|
||||
// Check for streaming override via env_vars.
|
||||
// Config/env may store the value as a string ("true") or a native bool,
|
||||
// so try String first, then fall back to bool.
|
||||
let global_config = Config::global();
|
||||
for var in env_vars {
|
||||
if var.name.ends_with("_STREAMING") {
|
||||
let val: Option<bool> = global_config
|
||||
.get_param::<String>(&var.name)
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase() == "true")
|
||||
.or_else(|| global_config.get_param::<bool>(&var.name).ok())
|
||||
.or_else(|| var.default.as_deref().map(|d| d.to_lowercase() == "true"));
|
||||
if let Some(v) = val {
|
||||
config.supports_streaming = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_declarative_provider(
|
||||
registry: &mut crate::providers::provider_registry::ProviderRegistry,
|
||||
config: DeclarativeProviderConfig,
|
||||
provider_type: ProviderType,
|
||||
) {
|
||||
// Expand env vars in base_url once, so individual engines don't need to
|
||||
let mut config = config;
|
||||
if let Some(ref env_vars) = config.env_vars {
|
||||
if let Ok(resolved) = expand_env_vars(&config.base_url, env_vars) {
|
||||
config.base_url = resolved;
|
||||
}
|
||||
}
|
||||
let config_clone = config.clone();
|
||||
|
||||
// Each closure needs its own owned copy of config because closures are
|
||||
// moved into the registry and may be invoked much later than registration.
|
||||
// Env var expansion happens lazily inside resolve_base_url so that values
|
||||
// configured through the UI after startup are picked up.
|
||||
match config.engine {
|
||||
ProviderEngine::OpenAI => {
|
||||
let captured = config.clone();
|
||||
registry.register_with_name::<OpenAiProvider, _>(
|
||||
&config,
|
||||
provider_type,
|
||||
move |model| OpenAiProvider::from_custom_config(model, config_clone.clone()),
|
||||
move |model| {
|
||||
let mut cfg = captured.clone();
|
||||
resolve_config(&mut cfg)?;
|
||||
OpenAiProvider::from_custom_config(model, cfg)
|
||||
},
|
||||
);
|
||||
}
|
||||
ProviderEngine::Ollama => {
|
||||
let captured = config.clone();
|
||||
registry.register_with_name::<OllamaProvider, _>(
|
||||
&config,
|
||||
provider_type,
|
||||
move |model| OllamaProvider::from_custom_config(model, config_clone.clone()),
|
||||
move |model| {
|
||||
let mut cfg = captured.clone();
|
||||
resolve_config(&mut cfg)?;
|
||||
OllamaProvider::from_custom_config(model, cfg)
|
||||
},
|
||||
);
|
||||
}
|
||||
ProviderEngine::Anthropic => {
|
||||
let captured = config.clone();
|
||||
registry.register_with_name::<AnthropicProvider, _>(
|
||||
&config,
|
||||
provider_type,
|
||||
move |model| AnthropicProvider::from_custom_config(model, config_clone.clone()),
|
||||
move |model| {
|
||||
let mut cfg = captured.clone();
|
||||
resolve_config(&mut cfg)?;
|
||||
AnthropicProvider::from_custom_config(model, cfg)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -453,7 +494,7 @@ mod tests {
|
||||
let config: DeclarativeProviderConfig =
|
||||
serde_json::from_str(json).expect("tanzu.json should parse");
|
||||
assert_eq!(config.name, "tanzu_ai");
|
||||
assert_eq!(config.display_name, "Tanzu AI Services");
|
||||
assert_eq!(config.display_name, "VMware Tanzu Platform");
|
||||
assert!(matches!(config.engine, ProviderEngine::OpenAI));
|
||||
assert_eq!(config.api_key_env, "TANZU_AI_API_KEY");
|
||||
assert_eq!(
|
||||
@@ -461,13 +502,16 @@ mod tests {
|
||||
"${TANZU_AI_ENDPOINT}/openai/v1/chat/completions"
|
||||
);
|
||||
assert_eq!(config.dynamic_models, Some(true));
|
||||
assert_eq!(config.supports_streaming, Some(false));
|
||||
assert_eq!(config.supports_streaming, Some(true));
|
||||
|
||||
let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
|
||||
assert_eq!(env_vars.len(), 1);
|
||||
assert_eq!(env_vars.len(), 2);
|
||||
assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT");
|
||||
assert!(env_vars[0].required);
|
||||
assert!(!env_vars[0].secret);
|
||||
assert_eq!(env_vars[1].name, "TANZU_AI_STREAMING");
|
||||
assert!(!env_vars[1].required);
|
||||
assert_eq!(env_vars[1].default, Some("true".to_string()));
|
||||
|
||||
assert_eq!(config.models.len(), 1);
|
||||
assert_eq!(config.models[0].name, "openai/gpt-oss-120b");
|
||||
@@ -490,6 +534,7 @@ mod tests {
|
||||
name: "TEST_EXPAND_HOST".to_string(),
|
||||
required: true,
|
||||
secret: false,
|
||||
primary: None,
|
||||
description: None,
|
||||
default: None,
|
||||
}];
|
||||
@@ -506,6 +551,7 @@ mod tests {
|
||||
name: "TEST_EXPAND_MISSING".to_string(),
|
||||
required: true,
|
||||
secret: false,
|
||||
primary: None,
|
||||
description: None,
|
||||
default: None,
|
||||
}];
|
||||
@@ -526,6 +572,7 @@ mod tests {
|
||||
name: "TEST_EXPAND_DEFAULT".to_string(),
|
||||
required: false,
|
||||
secret: false,
|
||||
primary: None,
|
||||
description: None,
|
||||
default: Some("https://fallback.example.com".to_string()),
|
||||
}];
|
||||
@@ -541,6 +588,7 @@ mod tests {
|
||||
name: "UNUSED_VAR".to_string(),
|
||||
required: true,
|
||||
secret: false,
|
||||
primary: None,
|
||||
description: None,
|
||||
default: None,
|
||||
}];
|
||||
@@ -564,6 +612,7 @@ mod tests {
|
||||
name: "TEST_EXPAND_OVERRIDE".to_string(),
|
||||
required: false,
|
||||
secret: false,
|
||||
primary: None,
|
||||
description: None,
|
||||
default: Some("https://from-default.com".to_string()),
|
||||
}];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "tanzu_ai",
|
||||
"engine": "openai",
|
||||
"display_name": "Tanzu AI Services",
|
||||
"description": "Enterprise-managed LLM access through VMware Tanzu Platform AI Services",
|
||||
"display_name": "VMware Tanzu Platform",
|
||||
"description": "Enterprise-managed LLM access through AI Services on VMware Tanzu Platform.",
|
||||
"api_key_env": "TANZU_AI_API_KEY",
|
||||
"base_url": "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions",
|
||||
"env_vars": [
|
||||
@@ -10,12 +10,20 @@
|
||||
"name": "TANZU_AI_ENDPOINT",
|
||||
"required": true,
|
||||
"secret": false,
|
||||
"description": "Your Tanzu AI Services endpoint URL"
|
||||
"description": "Your VMware Tanzu Platform AI Services endpoint URL"
|
||||
},
|
||||
{
|
||||
"name": "TANZU_AI_STREAMING",
|
||||
"required": false,
|
||||
"secret": false,
|
||||
"primary": true,
|
||||
"default": "true",
|
||||
"description": "Enable streaming responses (true/false)"
|
||||
}
|
||||
],
|
||||
"dynamic_models": true,
|
||||
"models": [
|
||||
{ "name": "openai/gpt-oss-120b", "context_limit": 131072 }
|
||||
],
|
||||
"supports_streaming": false
|
||||
"supports_streaming": true
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ mod tests {
|
||||
// Should be a Declarative (fixed) provider
|
||||
assert_eq!(*provider_type, ProviderType::Declarative);
|
||||
|
||||
assert_eq!(meta.display_name, "Tanzu AI Services");
|
||||
assert_eq!(meta.display_name, "VMware Tanzu Platform");
|
||||
assert_eq!(meta.default_model, "openai/gpt-oss-120b");
|
||||
|
||||
// First config key should be TANZU_AI_API_KEY (secret, required)
|
||||
|
||||
@@ -153,7 +153,17 @@ impl OpenAiProvider {
|
||||
let global_config = crate::config::Config::global();
|
||||
|
||||
let api_key: Option<String> = if config.requires_auth && !config.api_key_env.is_empty() {
|
||||
global_config.get_secret(&config.api_key_env).ok()
|
||||
Some(global_config.get_secret::<String>(&config.api_key_env).map_err(|e| {
|
||||
use crate::config::ConfigError;
|
||||
match e {
|
||||
ConfigError::NotFound(_) => anyhow::anyhow!(
|
||||
"Required API key {} is not set. Configure it via `goose configure` or set the {} environment variable.",
|
||||
config.api_key_env,
|
||||
config.api_key_env
|
||||
),
|
||||
other => anyhow::anyhow!("Failed to read {}: {}", config.api_key_env, other),
|
||||
}
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -125,12 +125,14 @@ impl ProviderRegistry {
|
||||
|
||||
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(
|
||||
&ev.name,
|
||||
ev.required,
|
||||
ev.secret,
|
||||
ev.default.as_deref(),
|
||||
false,
|
||||
primary,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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) |
|
||||
| [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) |
|
||||
| [Cerebras](https://cerebras.ai/) | Fast inference on Cerebras wafer-scale engines with models like Llama, Qwen, and others. | `CEREBRAS_API_KEY` |
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
sidebar_position: 15
|
||||
title: VMware Tanzu Platform
|
||||
description: Connect goose to VMware Tanzu Platform AI Services
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# VMware Tanzu Platform
|
||||
|
||||
[VMware Tanzu Platform](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform/ai-services/10-3/ai/index.html) provides enterprise-managed LLM access through AI Services. goose connects to VMware Tanzu Platform as an OpenAI-compatible provider, supporting both **single-model** and **multi-model** service plans with streaming enabled by default.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A VMware Tanzu Platform (TAS) foundation with GenAI tile installed and configured
|
||||
- Access to a CF org/space where the `genai` service is available in the marketplace
|
||||
- The CF CLI (`cf`) installed and authenticated (`cf login`)
|
||||
- goose v1.28.0 or later
|
||||
|
||||
## Step 1: Check Available Plans
|
||||
|
||||
First, verify the `genai` service is available in your marketplace and review the available plans:
|
||||
|
||||
```sh
|
||||
cf marketplace -e genai
|
||||
```
|
||||
|
||||
You will see output similar to:
|
||||
|
||||
```
|
||||
broker: genai-service
|
||||
plan description free or paid
|
||||
tanzu-Qwen3-Coder-30B-A3B-vllm-v1 Access to: Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8. free
|
||||
tanzu-gpt-oss-120b-vllm-v1 Access to: openai/gpt-oss-120b. free
|
||||
tanzu-all-models Access to: Qwen3.5-122B, Qwen3-Coder-30B, gpt-oss... free
|
||||
```
|
||||
|
||||
Each plan corresponds to a different model or set of models. **Single-model plans** give access to one model. **Multi-model plans** (e.g., `tanzu-all-models`) give access to multiple models behind a single endpoint.
|
||||
|
||||
## Step 2: Create a Service Instance
|
||||
|
||||
### Option A: Single-Model Plan
|
||||
|
||||
Create a service instance using a single-model plan:
|
||||
|
||||
```sh
|
||||
cf create-service genai tanzu-Qwen3-Coder-30B-A3B-vllm-v1 my-qwen-coder --wait
|
||||
```
|
||||
|
||||
### Option B: Multi-Model Plan
|
||||
|
||||
Create a service instance using the multi-model plan:
|
||||
|
||||
```sh
|
||||
cf create-service genai tanzu-all-models my-all-models --wait
|
||||
```
|
||||
|
||||
Verify the instance was created:
|
||||
|
||||
```sh
|
||||
cf services
|
||||
```
|
||||
|
||||
## Step 3: Create a Service Key
|
||||
|
||||
Create a service key to generate API credentials:
|
||||
|
||||
```sh
|
||||
cf create-service-key my-qwen-coder my-goose-key --wait
|
||||
```
|
||||
|
||||
Then retrieve the credentials:
|
||||
|
||||
```sh
|
||||
cf service-key my-qwen-coder my-goose-key
|
||||
```
|
||||
|
||||
### Single-Model Plan Output
|
||||
|
||||
For a single-model plan, the output includes model metadata at the top level:
|
||||
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"api_base": "https://genai-proxy.sys.example.com/tanzu-my-model-abc1234/openai",
|
||||
"api_key": "eyJhbGciOi...",
|
||||
"endpoint": {
|
||||
"api_base": "https://genai-proxy.sys.example.com/tanzu-my-model-abc1234",
|
||||
"api_key": "eyJhbGciOi...",
|
||||
"config_url": "https://genai-proxy.sys.example.com/tanzu-my-model-abc1234/config/v1/endpoint",
|
||||
"name": "tanzu-my-model-abc1234"
|
||||
},
|
||||
"model_capabilities": ["chat", "tools"],
|
||||
"model_name": "Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8",
|
||||
"wire_format": "openai"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Model Plan Output
|
||||
|
||||
For a multi-model plan, the output only contains the endpoint object:
|
||||
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"endpoint": {
|
||||
"api_base": "https://genai-proxy.sys.example.com/tanzu-all-models-abc1234",
|
||||
"api_key": "eyJhbGciOi...",
|
||||
"config_url": "https://genai-proxy.sys.example.com/tanzu-all-models-abc1234/config/v1/endpoint",
|
||||
"name": "tanzu-all-models-abc1234"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Identify Your Endpoint and API Key
|
||||
|
||||
From the service key output, you need two values from the **`credentials.endpoint`** object:
|
||||
|
||||
| Value | JSON Path | Example |
|
||||
|-------|-----------|---------|
|
||||
| **Endpoint URL** | `credentials.endpoint.api_base` | `https://genai-proxy.sys.example.com/tanzu-my-model-abc1234` |
|
||||
| **API Key** | `credentials.endpoint.api_key` | `eyJhbGciOi...` (JWT token) |
|
||||
|
||||
:::warning Use `credentials.endpoint.api_base`, not `credentials.api_base`
|
||||
Single-model plans include a top-level `credentials.api_base` field that has an `/openai` suffix. **Do not use this value.** Always use `credentials.endpoint.api_base` (without `/openai`), because goose automatically appends the correct path.
|
||||
|
||||
Using the wrong value would produce a double-path URL like `.../openai/openai/v1/chat/completions`.
|
||||
:::
|
||||
|
||||
## Step 5: Configure goose
|
||||
|
||||
<Tabs groupId="interface">
|
||||
<TabItem value="ui" label="goose Desktop" default>
|
||||
|
||||
1. Open goose Desktop
|
||||
2. Click the sidebar button, then **Settings** > **Models** > **Configure providers**
|
||||
3. Find **VMware Tanzu Platform** in the provider list and click **Configure**
|
||||
4. Enter your values:
|
||||
- **TANZU_AI_ENDPOINT**: Paste the `credentials.endpoint.api_base` URL
|
||||
- **TANZU_AI_API_KEY**: Paste the `credentials.endpoint.api_key` JWT token
|
||||
5. Click **Submit**
|
||||
6. Select a model from the dynamically fetched list
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="goose CLI">
|
||||
|
||||
### Option 1: Using `goose configure`
|
||||
|
||||
```sh
|
||||
goose configure
|
||||
```
|
||||
|
||||
1. Select **Configure Providers**
|
||||
2. Choose **VMware Tanzu Platform** from the list
|
||||
3. Enter your `TANZU_AI_ENDPOINT` when prompted
|
||||
4. Enter your `TANZU_AI_API_KEY` when prompted
|
||||
5. Select a model from the fetched list
|
||||
|
||||
### Option 2: Using environment variables
|
||||
|
||||
Set the following environment variables before launching goose:
|
||||
|
||||
```sh
|
||||
export TANZU_AI_ENDPOINT="https://genai-proxy.sys.example.com/tanzu-my-model-abc1234"
|
||||
export TANZU_AI_API_KEY="eyJhbGciOi..."
|
||||
```
|
||||
|
||||
Then start goose:
|
||||
|
||||
```sh
|
||||
goose session
|
||||
```
|
||||
|
||||
:::tip
|
||||
Add these exports to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to persist them across sessions.
|
||||
:::
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Step 6: Select a Model
|
||||
|
||||
goose dynamically fetches available models from your Tanzu endpoint. After configuring the provider:
|
||||
|
||||
- **Single-model plan**: The one available model will be listed (e.g., `Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8`)
|
||||
- **Multi-model plan**: All models on the plan will be listed, and you can switch between them
|
||||
|
||||
To change models later, use **Settings** > **Models** > **Switch models** in Desktop, or run `goose configure` in the CLI.
|
||||
|
||||
:::note
|
||||
Embedding-only models (e.g., `nomic-ai/nomic-embed-text-v2-moe`) will appear in the model list but cannot be used as a chat model.
|
||||
:::
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Could not contact provider" / 401 Unauthorized on models endpoint
|
||||
|
||||
This means the API key is not being sent correctly. Common causes:
|
||||
|
||||
1. **Environment variables not set**: If using goose Desktop, env vars from your shell may not be inherited. Use the Settings UI to configure the provider instead.
|
||||
2. **Wrong `api_base`**: Make sure you used `credentials.endpoint.api_base` (without `/openai`), not `credentials.api_base`.
|
||||
3. **Expired API key**: Tanzu API keys are JWT tokens that may expire. Generate a new service key with `cf create-service-key`.
|
||||
|
||||
### Verify your endpoint manually
|
||||
|
||||
You can test connectivity with curl:
|
||||
|
||||
```sh
|
||||
# Test model discovery
|
||||
curl -H "Authorization: Bearer $TANZU_AI_API_KEY" \
|
||||
"$TANZU_AI_ENDPOINT/openai/v1/models"
|
||||
|
||||
# Test chat completions
|
||||
curl -X POST "$TANZU_AI_ENDPOINT/openai/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $TANZU_AI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"YOUR_MODEL_NAME","messages":[{"role":"user","content":"hello"}]}'
|
||||
```
|
||||
|
||||
### Streaming
|
||||
|
||||
Streaming is enabled by default. If your endpoint does not support streaming, you can disable it by unchecking the **Streaming** checkbox in the provider configuration UI, or by setting the `TANZU_AI_STREAMING` environment variable to `false`.
|
||||
|
||||
### Model not found
|
||||
|
||||
If the model you selected returns an error, verify available models on your plan:
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: Bearer $TANZU_AI_API_KEY" \
|
||||
"$TANZU_AI_ENDPOINT/openai/v1/models"
|
||||
```
|
||||
|
||||
Ensure the model name matches exactly (including the prefix, e.g., `Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8`).
|
||||
|
||||
### Cleaning up
|
||||
|
||||
To remove a service instance and its keys:
|
||||
|
||||
```sh
|
||||
cf delete-service-key my-qwen-coder my-goose-key -f
|
||||
cf delete-service my-qwen-coder -f
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# VMware Tanzu Platform - CLI Testing Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- goose CLI built from the `feat/tanzu-ai-provider` branch
|
||||
- A Tanzu AI Services endpoint and API key (single-model or multi-model plan)
|
||||
|
||||
## Locate the CLI Binary
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
# If built from source:
|
||||
export GOOSE_CLI=~/claude/goose-fork/target/release/goose
|
||||
|
||||
# Verify:
|
||||
$GOOSE_CLI --version
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
# If installed via .deb:
|
||||
export GOOSE_CLI=/usr/bin/goose
|
||||
|
||||
# If built from source:
|
||||
export GOOSE_CLI=~/goose-fork/target/release/goose
|
||||
|
||||
# Verify:
|
||||
$GOOSE_CLI --version
|
||||
```
|
||||
|
||||
## Test 1: Configure VMware Tanzu Platform Provider
|
||||
|
||||
```bash
|
||||
goose configure
|
||||
```
|
||||
|
||||
1. Select **Configure Providers**
|
||||
2. Scroll to / search for **VMware Tanzu Platform**
|
||||
3. When prompted for **TANZU_AI_ENDPOINT**, enter your endpoint URL:
|
||||
- Single-model: `https://genai-proxy.sys.example.com/tanzu-my-model-abc1234`
|
||||
- Multi-model: `https://genai-proxy.sys.example.com/tanzu-all-models-abc1234`
|
||||
4. When prompted for **TANZU_AI_API_KEY**, paste the JWT token from your service key
|
||||
5. Select a model from the dynamically fetched list
|
||||
|
||||
**Expected:** Models are fetched from the endpoint and displayed for selection.
|
||||
|
||||
## Test 2: Start a Session (Single-Model Plan)
|
||||
|
||||
```bash
|
||||
export TANZU_AI_ENDPOINT="https://genai-proxy.sys.tas-tdc.kuhn-labs.com/tanzu-Qwen3-Coder-30B-A3B-vllm-v1-f3b0d18"
|
||||
export TANZU_AI_API_KEY="<your-jwt-token>"
|
||||
|
||||
goose session
|
||||
```
|
||||
|
||||
Type a simple prompt:
|
||||
```
|
||||
> What is 2 + 2?
|
||||
```
|
||||
|
||||
**Expected:** The model responds with an answer. If streaming is enabled, tokens appear incrementally.
|
||||
|
||||
## Test 3: Start a Session (Multi-Model Plan)
|
||||
|
||||
```bash
|
||||
export TANZU_AI_ENDPOINT="https://genai-proxy.sys.tas-tdc.kuhn-labs.com/tanzu-all-models-a8a9e22"
|
||||
export TANZU_AI_API_KEY="<your-jwt-token>"
|
||||
|
||||
goose session
|
||||
```
|
||||
|
||||
**Expected:** Session starts with whichever model was selected during `goose configure`.
|
||||
|
||||
## Test 4: Verify Streaming
|
||||
|
||||
With streaming enabled (`supports_streaming: true`), responses should appear token-by-token rather than all at once.
|
||||
|
||||
```
|
||||
> Write a short poem about clouds
|
||||
```
|
||||
|
||||
**Expected:** Text streams in progressively, not appearing all at once after a delay.
|
||||
|
||||
## Test 5: Verify Dynamic Model Fetching
|
||||
|
||||
```bash
|
||||
goose configure
|
||||
```
|
||||
|
||||
Select **Configure Providers** > **VMware Tanzu Platform**.
|
||||
|
||||
**Expected for single-model plan:** One model appears (e.g., `Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8`)
|
||||
**Expected for multi-model plan:** Multiple models appear (e.g., `Qwen3.5-122B`, `Qwen3-Coder-30B`, `gpt-oss-120b`)
|
||||
|
||||
## Test 6: Verify Error Messages
|
||||
|
||||
### Missing API Key
|
||||
```bash
|
||||
unset TANZU_AI_API_KEY
|
||||
goose session
|
||||
```
|
||||
**Expected:** Clear error message: "Required API key TANZU_AI_API_KEY is not set."
|
||||
|
||||
### Missing Endpoint
|
||||
```bash
|
||||
unset TANZU_AI_ENDPOINT
|
||||
goose session
|
||||
```
|
||||
**Expected:** Clear error message about TANZU_AI_ENDPOINT not being set.
|
||||
|
||||
### Wrong Endpoint
|
||||
```bash
|
||||
export TANZU_AI_ENDPOINT="https://genai-proxy.sys.example.com/nonexistent"
|
||||
export TANZU_AI_API_KEY="invalid-key"
|
||||
goose session
|
||||
```
|
||||
**Expected:** Connection or authentication error, not a crash.
|
||||
|
||||
## Test 7: Switch Between Plans
|
||||
|
||||
1. Configure with multi-model endpoint, select a model, start a session, verify it works
|
||||
2. Run `goose configure` again
|
||||
3. Change TANZU_AI_ENDPOINT to the single-model endpoint
|
||||
4. Select the single model
|
||||
5. Start a new session, verify it works
|
||||
|
||||
**Expected:** Both plans work without needing to restart goose.
|
||||
|
||||
## Quick Curl Verification
|
||||
|
||||
Before testing with goose, you can verify endpoints directly:
|
||||
|
||||
```bash
|
||||
# Test models endpoint
|
||||
curl -s -H "Authorization: Bearer $TANZU_AI_API_KEY" \
|
||||
"$TANZU_AI_ENDPOINT/openai/v1/models" | python3 -m json.tool
|
||||
|
||||
# Test chat completions
|
||||
curl -s -X POST "$TANZU_AI_ENDPOINT/openai/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $TANZU_AI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8","messages":[{"role":"user","content":"hello"}],"max_tokens":10}'
|
||||
|
||||
# Test streaming
|
||||
curl -s -N -X POST "$TANZU_AI_ENDPOINT/openai/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $TANZU_AI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8","messages":[{"role":"user","content":"hello"}],"max_tokens":10,"stream":true}'
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
# build-windows.ps1
|
||||
# Build Goose Desktop for Windows with VMware Tanzu Platform provider
|
||||
# Run this script from the root of the goose-fork repository in PowerShell
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Git (https://git-scm.com/download/win)
|
||||
# - Rust (https://rustup.rs)
|
||||
# - Node.js v24+ (https://nodejs.org)
|
||||
# - pnpm: npm install -g pnpm
|
||||
#
|
||||
# Usage:
|
||||
# cd C:\path\to\goose-fork
|
||||
# .\scripts\build-windows.ps1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== Goose Windows Build Script ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Check prerequisites
|
||||
Write-Host "[1/7] Checking prerequisites..." -ForegroundColor Yellow
|
||||
|
||||
$missing = @()
|
||||
if (-not (Get-Command "cargo" -ErrorAction SilentlyContinue)) { $missing += "Rust (install from https://rustup.rs)" }
|
||||
if (-not (Get-Command "node" -ErrorAction SilentlyContinue)) { $missing += "Node.js v24+ (install from https://nodejs.org)" }
|
||||
if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) { $missing += "pnpm (run: npm install -g pnpm)" }
|
||||
if (-not (Get-Command "git" -ErrorAction SilentlyContinue)) { $missing += "Git (install from https://git-scm.com)" }
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
Write-Host "Missing prerequisites:" -ForegroundColor Red
|
||||
foreach ($m in $missing) {
|
||||
Write-Host " - $m" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " cargo: $(cargo --version)" -ForegroundColor Green
|
||||
Write-Host " node: $(node --version)" -ForegroundColor Green
|
||||
Write-Host " pnpm: $(pnpm --version)" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 1: Clone or update repo
|
||||
Write-Host "[2/7] Building Rust backend (release)..." -ForegroundColor Yellow
|
||||
Write-Host " This may take 5-15 minutes on first build..."
|
||||
cargo build --release -p goose-server
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Rust build failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Rust build complete." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 2: Copy binaries
|
||||
Write-Host "[3/7] Copying binaries to desktop app..." -ForegroundColor Yellow
|
||||
$binDir = "ui\desktop\src\bin"
|
||||
if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
|
||||
|
||||
Copy-Item "target\release\goosed.exe" "$binDir\" -Force
|
||||
if (Test-Path "target\release\goose.exe") {
|
||||
Copy-Item "target\release\goose.exe" "$binDir\" -Force
|
||||
}
|
||||
# Copy required DLLs if they exist (from cross-compilation)
|
||||
Get-ChildItem "target\release\*.dll" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Copy-Item $_.FullName "$binDir\" -Force
|
||||
}
|
||||
Write-Host " Binaries copied." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 3: Install npm dependencies
|
||||
Write-Host "[4/7] Installing npm dependencies..." -ForegroundColor Yellow
|
||||
Push-Location "ui\desktop"
|
||||
pnpm install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "npm install failed!" -ForegroundColor Red
|
||||
Pop-Location
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Dependencies installed." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 4: Generate API types
|
||||
Write-Host "[5/7] Generating API types..." -ForegroundColor Yellow
|
||||
pnpm run generate-api
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "API type generation failed!" -ForegroundColor Red
|
||||
Pop-Location
|
||||
exit 1
|
||||
}
|
||||
Write-Host " API types generated." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 5: Package
|
||||
Write-Host "[6/7] Packaging Goose Desktop..." -ForegroundColor Yellow
|
||||
npx electron-forge package
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Packaging failed!" -ForegroundColor Red
|
||||
Pop-Location
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Packaging complete." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Step 6: Make installer
|
||||
Write-Host "[7/7] Creating Windows installer..." -ForegroundColor Yellow
|
||||
npx electron-forge make
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Make failed! Trying with squirrel only..." -ForegroundColor Yellow
|
||||
npx electron-forge make --targets=@electron-forge/maker-squirrel
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Fallback installer build also failed!" -ForegroundColor Red
|
||||
Pop-Location
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Pop-Location
|
||||
Write-Host ""
|
||||
|
||||
# Done
|
||||
Write-Host "=== Build Complete ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Packaged app: ui\desktop\out\Goose-win32-x64\Goose.exe" -ForegroundColor Green
|
||||
Write-Host "Installer: ui\desktop\out\make\" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "To run the app directly:" -ForegroundColor Yellow
|
||||
Write-Host " .\ui\desktop\out\Goose-win32-x64\Goose.exe"
|
||||
Write-Host ""
|
||||
Write-Host "To install, find the .exe installer in ui\desktop\out\make\"
|
||||
@@ -4858,6 +4858,11 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the field is shown prominently in the UI (not collapsed).\nDefaults to the value of `required` if not specified.",
|
||||
"nullable": true
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -331,6 +331,11 @@ export type EnvVarConfig = {
|
||||
default?: string | null;
|
||||
description?: string | null;
|
||||
name: string;
|
||||
/**
|
||||
* When true, the field is shown prominently in the UI (not collapsed).
|
||||
* Defaults to the value of `required` if not specified.
|
||||
*/
|
||||
primary?: boolean | null;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import OpenRouterLogo from './icons/openrouter@3x.png';
|
||||
import SnowflakeLogo from './icons/snowflake@3x.png';
|
||||
import XaiLogo from './icons/xai@3x.png';
|
||||
import MiniMaxLogo from './icons/minimax@3x.png';
|
||||
import TanzuLogo from './icons/tanzu@3x.png';
|
||||
import DefaultLogo from './icons/default@3x.png';
|
||||
|
||||
// Map provider names to their logos
|
||||
@@ -22,6 +23,7 @@ const providerLogos: Record<string, string> = {
|
||||
snowflake: SnowflakeLogo,
|
||||
xai: XaiLogo,
|
||||
minimax: MiniMaxLogo,
|
||||
tanzu_ai: TanzuLogo,
|
||||
default: DefaultLogo,
|
||||
};
|
||||
|
||||
|
||||
+100
-38
@@ -60,7 +60,7 @@ export default function DefaultProviderSetupForm({
|
||||
const configKey = `${parameter.name}`;
|
||||
const configValue = (await read(configKey, parameter.secret || false)) as ConfigValue;
|
||||
|
||||
if (configValue) {
|
||||
if (configValue !== undefined && configValue !== null) {
|
||||
values[parameter.name] = { serverValue: configValue };
|
||||
} else if (parameter.default !== undefined && parameter.default !== null) {
|
||||
values[parameter.name] = { value: parameter.default };
|
||||
@@ -127,47 +127,109 @@ export default function DefaultProviderSetupForm({
|
||||
return <div className="text-center py-4">Loading configuration values...</div>;
|
||||
}
|
||||
|
||||
function getRenderValue(parameter: ConfigKey): string | undefined {
|
||||
if (parameter.secret) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRenderValue(parameter: ConfigKey): string {
|
||||
const entry = configValues[parameter.name];
|
||||
return entry?.value || (entry?.serverValue as string) || '';
|
||||
// If the user has edited the field (even to empty string), use their value.
|
||||
// This prevents the input from snapping back to the stored serverValue
|
||||
// when the user backspaces to clear the field.
|
||||
if (entry?.value !== undefined) {
|
||||
return entry.value;
|
||||
}
|
||||
if (parameter.secret) {
|
||||
return '';
|
||||
}
|
||||
// Convert serverValue to string explicitly — native booleans (false) would
|
||||
// be falsy and get collapsed to '' by the || operator, losing the value.
|
||||
if (entry?.serverValue !== undefined && entry?.serverValue !== null) {
|
||||
return String(entry.serverValue);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Detect boolean parameters (default is "true" or "false")
|
||||
function isBooleanParameter(parameter: ConfigKey): boolean {
|
||||
const def = parameter.default?.toLowerCase();
|
||||
return def === 'true' || def === 'false';
|
||||
}
|
||||
|
||||
function getBooleanValue(parameter: ConfigKey): boolean {
|
||||
const raw = getRenderValue(parameter);
|
||||
const val = String(raw).toLowerCase();
|
||||
if (val === '' && parameter.default) {
|
||||
return parameter.default.toLowerCase() === 'true';
|
||||
}
|
||||
return val === 'true';
|
||||
}
|
||||
|
||||
// Pretty label for boolean toggle (strip provider prefix, humanize)
|
||||
function getBooleanLabel(parameter: ConfigKey): string {
|
||||
let name = parameter.name.toUpperCase();
|
||||
const prefix = provider.name.toUpperCase().replace('-', '_') + '_';
|
||||
if (name.startsWith(prefix)) {
|
||||
name = name.slice(prefix.length);
|
||||
}
|
||||
return envToPrettyName(name);
|
||||
}
|
||||
|
||||
const renderParametersList = (parameters: ConfigKey[]) => {
|
||||
return parameters.map((parameter) => (
|
||||
<div key={parameter.name}>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
{getFieldLabel(parameter)}
|
||||
{parameter.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={getRenderValue(parameter)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfigValues((prev) => {
|
||||
const newValue = { ...(prev[parameter.name] || {}), value: e.target.value };
|
||||
return {
|
||||
...prev,
|
||||
[parameter.name]: newValue,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder={getPlaceholder(parameter)}
|
||||
className={`w-full h-14 px-4 font-regular rounded-lg shadow-none ${
|
||||
validationErrors[parameter.name]
|
||||
? 'border-2 border-red-500'
|
||||
: 'border border-border-primary hover:border-border-primary'
|
||||
} bg-background-primary text-lg placeholder:text-text-secondary font-regular text-text-primary`}
|
||||
required={parameter.required}
|
||||
/>
|
||||
{validationErrors[parameter.name] && (
|
||||
<p className="text-red-500 text-sm mt-1">{validationErrors[parameter.name]}</p>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
return parameters.map((parameter) => {
|
||||
if (isBooleanParameter(parameter)) {
|
||||
return (
|
||||
<div key={parameter.name} className="flex items-center space-x-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`toggle-${parameter.name}`}
|
||||
checked={getBooleanValue(parameter)}
|
||||
onChange={(e) => {
|
||||
setConfigValues((prev) => ({
|
||||
...prev,
|
||||
[parameter.name]: {
|
||||
...(prev[parameter.name] || {}),
|
||||
value: e.target.checked ? 'true' : 'false',
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="rounded border-border-primary h-4 w-4"
|
||||
/>
|
||||
<label htmlFor={`toggle-${parameter.name}`} className="text-sm text-text-secondary">
|
||||
{getBooleanLabel(parameter)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={parameter.name}>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||||
{getFieldLabel(parameter)}
|
||||
{parameter.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={getRenderValue(parameter)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfigValues((prev) => {
|
||||
const newValue = { ...(prev[parameter.name] || {}), value: e.target.value };
|
||||
return {
|
||||
...prev,
|
||||
[parameter.name]: newValue,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder={getPlaceholder(parameter)}
|
||||
className={`w-full h-14 px-4 font-regular rounded-lg shadow-none ${
|
||||
validationErrors[parameter.name]
|
||||
? 'border-2 border-red-500'
|
||||
: 'border border-border-primary hover:border-border-primary'
|
||||
} bg-background-primary text-lg placeholder:text-text-secondary font-regular text-text-primary`}
|
||||
required={parameter.required}
|
||||
/>
|
||||
{validationErrors[parameter.name] && (
|
||||
<p className="text-red-500 text-sm mt-1">{validationErrors[parameter.name]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
let aboveFoldParameters = parameters.filter(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Reference in New Issue
Block a user