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 00000000..fe855e2b Binary files /dev/null and b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu.png differ diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@2x.png new file mode 100644 index 00000000..17b8a2b4 Binary files /dev/null and b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@2x.png differ diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@3x.png new file mode 100644 index 00000000..02b5c484 Binary files /dev/null and b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/tanzu@3x.png differ