From c936514014aff0069f2dda6e08d21b547e8aa16a Mon Sep 17 00:00:00 2001 From: Nick Kuhn <95254386+nkuhn-vmw@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:16:01 -0400 Subject: [PATCH] fix: VMware Tanzu Platform provider - bug fixes, streaming, UI improvements (#8126) Signed-off-by: Nick Kuhn Signed-off-by: Douwe Osinga Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Douwe Osinga --- .../goose/src/config/declarative_providers.rs | 79 ++++-- .../src/providers/declarative/tanzu.json | 16 +- crates/goose/src/providers/init.rs | 2 +- crates/goose/src/providers/openai.rs | 12 +- .../goose/src/providers/provider_registry.rs | 4 +- .../docs/getting-started/providers.md | 2 +- .../docs/guides/tanzu-ai-services.md | 245 ++++++++++++++++++ .../docs/guides/tanzu-cli-testing-guide.md | 149 +++++++++++ scripts/build-windows.ps1 | 127 +++++++++ ui/desktop/openapi.json | 5 + ui/desktop/src/api/types.gen.ts | 5 + .../modal/subcomponents/ProviderLogo.tsx | 2 + .../forms/DefaultProviderSetupForm.tsx | 138 +++++++--- .../modal/subcomponents/icons/tanzu.png | Bin 0 -> 2410 bytes .../modal/subcomponents/icons/tanzu@2x.png | Bin 0 -> 6256 bytes .../modal/subcomponents/icons/tanzu@3x.png | Bin 0 -> 10299 bytes 16 files changed, 725 insertions(+), 61 deletions(-) create mode 100644 documentation/docs/guides/tanzu-ai-services.md create mode 100644 documentation/docs/guides/tanzu-cli-testing-guide.md create mode 100644 scripts/build-windows.ps1 create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu.png create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@2x.png create mode 100644 ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@3x.png diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 95f8288a..acd35714 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -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, pub description: Option, pub default: Option, } @@ -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 = global_config + .get_param::(&var.name) + .ok() + .map(|s| s.to_lowercase() == "true") + .or_else(|| global_config.get_param::(&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::( &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::( &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::( &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()), }]; diff --git a/crates/goose/src/providers/declarative/tanzu.json b/crates/goose/src/providers/declarative/tanzu.json index 4e980eaf..6ebabd7d 100644 --- a/crates/goose/src/providers/declarative/tanzu.json +++ b/crates/goose/src/providers/declarative/tanzu.json @@ -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 } diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 7b03f80a..53e48ad1 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -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) diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 65c7faef..5668d0df 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -153,7 +153,17 @@ impl OpenAiProvider { let global_config = crate::config::Config::global(); let api_key: Option = if config.requires_auth && !config.api_key_env.is_empty() { - global_config.get_secret(&config.api_key_env).ok() + Some(global_config.get_secret::(&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 }; diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index 611212dd..c6420344 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -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, )); } } diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 43385184..cb518eba 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -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` | diff --git a/documentation/docs/guides/tanzu-ai-services.md b/documentation/docs/guides/tanzu-ai-services.md new file mode 100644 index 00000000..30c476ed --- /dev/null +++ b/documentation/docs/guides/tanzu-ai-services.md @@ -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 + + + + + 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 + + + + + ### 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. + ::: + + + + +## 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 +``` diff --git a/documentation/docs/guides/tanzu-cli-testing-guide.md b/documentation/docs/guides/tanzu-cli-testing-guide.md new file mode 100644 index 00000000..612e565a --- /dev/null +++ b/documentation/docs/guides/tanzu-cli-testing-guide.md @@ -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="" + +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="" + +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}' +``` diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 00000000..4807e53e --- /dev/null +++ b/scripts/build-windows.ps1 @@ -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\" diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a0150149..bbbd8f57 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -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" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 5bc19901..e88a9736 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -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; }; diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx index e81a0a7d..4e0c3e3a 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx @@ -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 = { snowflake: SnowflakeLogo, xai: XaiLogo, minimax: MiniMaxLogo, + tanzu_ai: TanzuLogo, default: DefaultLogo, }; diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx index d1486352..f8c61cbb 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx @@ -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
Loading configuration values...
; } - 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) => ( -
- - ) => { - 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] && ( -

{validationErrors[parameter.name]}

- )} -
- )); + return parameters.map((parameter) => { + if (isBooleanParameter(parameter)) { + return ( +
+ { + setConfigValues((prev) => ({ + ...prev, + [parameter.name]: { + ...(prev[parameter.name] || {}), + value: e.target.checked ? 'true' : 'false', + }, + })); + }} + className="rounded border-border-primary h-4 w-4" + /> + +
+ ); + } + + return ( +
+ + ) => { + 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] && ( +

{validationErrors[parameter.name]}

+ )} +
+ ); + }); }; let aboveFoldParameters = parameters.filter( diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu.png new file mode 100644 index 0000000000000000000000000000000000000000..fe855e2b204624de8b18011b04e484bab64dcfbe GIT binary patch literal 2410 zcmV-w36=JVP)Ewx;M21qu$o87(l^pBW~B)gmNn9kVme>?m8{m$q9 z?m6e4-|q;{fP%rG>d3~jEOENyoN|E90A|vuf}gb0=|HyC$wGJ9_)J2!RcE0)Wz-R< zWm$r%U4?i(b7_ox%EW#Psoe1(e*cZcA~TtA{nwp}(Sc+~kl6;YEK68Vt`4$f$6TcI z6)w+sG;s)}1c*aOITGFdB}@MNyftgn`PqOv8Vj;ywj)TYYR^Ubp8%%e^$X(BH8j*e z!<2!0G2YQIZ?akVPEYazGjMqxMmdmds>F7lWkSC%JB;61*i3;e%M$#0TMwi@1x-GI z%Ohxttwbf~Grs@dGYwAVyBFi~PDRrvA_OE8Rivn3V&DB)$2wJ5Yu2XoajB_j`eSG= zNXD0<;uVZ5`bVpgVd(4+Jb> z$;8Ou>>=B-0cVBn(c>=n?%hk0Z37Tm=)=b-l$eDtrw8%Kc9b$%HSMi-wxgac0%70@ z{DgR<4x#2^N0ze2tZ!$FA%npxyF=J?S-rnc`L?L8?pyNuI17M>Dgq?sNRYGe1%kvQ z@1bmyRWsgh_E3UQSQ0LA2!$lQdMQlP0bq1Re;iqcH*hQQ$T3j!h)0(4YI&VeRwlpv z@WQ+F(63(p_V3UvySCPtKu4Dq8mH?s?_u zVD(H{6^NS}n~oo>+%oBNm_!0`D=u$O;`O(*X3{VudwUDQ!X`|6P=IjQJ`sAxcHmA% zJbNu}&yG||{!GBG<-dFE@FRCF{um){Y8xUL3@XdAgq|+GWm&>e&H;pYAB2*CBPb%) zaDX+XZ?{64X)>8f--SNZOR#$S`wW}^5mFWjKmkIC_fTrY_{+w-s`gd2TPtZlx4*ch z@@$vx`2naJ02@Ps>W^SMh9RjT{VP8iXv2^Q`7UtHfYHmn0FH9DdfhpJBmTmR)49sF zQ^2w;p}Fm=T$-B{`gQ={(A*yAo^wIew9dWLm1#QQ)Z+I0wV99rR_vHBt`04fG;9PzlbNI#}KMG z16O+~C~_R9?nhLGgKq!>hj$(lG?NAD8G@)Jc$iT$~ z9l`>=+`2EIgxU&F5HbQt?FtYz0MukZ19w`7_O#5&8g%ysIioAi0kBcqblsy#pS-3+ zSfy$GZ6~E-O8f=jsMmivZoeCxcL6xN_n9egTH`p|(A`8G=SqNj^p6v%cmjuZ^EOSE z)S#4;JhH6M;R66EhrZZJu*C-e^xAV~V8fy2dFv5-NJMRvyj}p+TP7Y=IG-zF5AFo4 zxLgQPjcGc~?@0_I5j()T(tl$Z@{Da=2!&xtmX$VO+kZuIXUn`0LR3qk2Rnonw%zxI zHzqxfVdx4#Vd2Z|PRZQsFiod#&Ftq%KcylfV4mtqqL)r^(Oq#1DT$i_F zgZ5{G0lylchfBhCC=}Adv0feuy6i$|`PhxUSv9i`W41WG3@d>#Td*51gK7m55 za^rYk_l&UVinHiZXNR{7uZ>yeIZqi%ighzLP=91Tfx=%h?3p3o0;fH-WmIUr)(kqQ zh(P}HG#q(JteyGNeWCSQ=r@6UcULwhV`^Vpw-&yP8~m2Z{_7B=w1DgNVuUO*44vxI znM9BO5l`S1hR*3pt(hVe>Rg5mqq%~(|LJpZ=WU>=@dNx_CKefn9uAk-d3mZ@NO9KY zNBoy%3elPw%d$k3LU@TCzelZ@k`e+ndR6Y6oI>rkqhG5MVEuPpJ6$jW+*T1O z?fL_KLEYE(m{lVqD+LPmz`TA0^0yJIU&-njV=_Xv)rbWP3FJ@V_`yM9ZP}X&!HAaw zgY&y`b*G-IlZLBY?U8iBQ0R!Ce zT;%7=M?{uNIH{7MxiD1X!RoxhZ%)bCn91&>x`-|6s9$=x{FQ;Wzu+n&4GNJZmO|mn z6y cS2v9R1r^H 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 a^8W*xgry0Q=>7cw0000 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 N002ovPDHLkV1oFz#Jd0h literal 0 HcmV?d00001