From 7761e7626ffe0700ec18a133178c9a8a9f940650 Mon Sep 17 00:00:00 2001 From: Luke <92253590+ImLukeF@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:31:06 +1100 Subject: [PATCH] Providers: add Opencode Go support (#42313) * feat(providers): add opencode-go provider support and onboarding * Onboard: unify OpenCode auth handling openclaw#42313 thanks @ImLukeF * Docs: merge OpenCode Zen and Go docs openclaw#42313 thanks @ImLukeF * Update CHANGELOG.md --------- Co-authored-by: Ubuntu Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + docs/cli/index.md | 3 +- docs/concepts/model-providers.md | 13 ++-- docs/concepts/models.md | 4 +- docs/docs.json | 8 ++- docs/gateway/configuration-reference.md | 4 +- docs/gateway/doctor.md | 12 ++-- docs/help/testing.md | 4 +- docs/providers/index.md | 2 +- docs/providers/models.md | 2 +- docs/providers/opencode-go.md | 45 ++++++++++++++ docs/providers/opencode.md | 50 ++++++++++++---- docs/reference/wizard.md | 5 +- docs/start/wizard-cli-automation.md | 3 +- docs/start/wizard-cli-reference.md | 4 +- src/agents/live-model-filter.ts | 2 +- src/agents/model-auth-env-vars.ts | 1 + src/agents/model-auth.profiles.test.ts | 14 +++++ src/agents/model-compat.test.ts | 6 ++ src/agents/model-selection.ts | 3 + src/agents/models.profiles.live.test.ts | 54 ++++++++++------- src/agents/provider-capabilities.test.ts | 7 +++ src/agents/provider-capabilities.ts | 5 ++ .../tools/web-fetch.cf-markdown.test.ts | 2 +- .../tools/web-tools.enabled-defaults.test.ts | 2 +- .../reply/directive-handling.model-picker.ts | 1 + src/cli/program/register.onboard.ts | 1 + src/commands/auth-choice-options.test.ts | 13 ++++ src/commands/auth-choice-options.ts | 16 +++-- .../auth-choice.apply.api-providers.ts | 35 +++++++++-- .../auth-choice.preferred-provider.ts | 1 + src/commands/auth-choice.test.ts | 40 +++++++++++-- src/commands/doctor-config-analysis.ts | 8 ++- ...rns-state-directory-is-missing.e2e.test.ts | 8 ++- .../onboard-auth.config-opencode-go.ts | 36 +++++++++++ src/commands/onboard-auth.credentials.test.ts | 23 ++++++++ src/commands/onboard-auth.credentials.ts | 29 +++++++-- src/commands/onboard-auth.test.ts | 11 ++++ src/commands/onboard-auth.ts | 5 ++ ...oard-non-interactive.provider-auth.test.ts | 59 ++++--------------- .../local/auth-choice-inference.ts | 1 + .../local/auth-choice.ts | 29 +++++++++ src/commands/onboard-provider-auth-flags.ts | 10 +++- src/commands/onboard-types.ts | 4 +- src/commands/opencode-go-model-default.ts | 11 ++++ src/secrets/provider-env-vars.ts | 1 + src/secrets/runtime-web-tools.test.ts | 8 +-- src/secrets/runtime-web-tools.ts | 2 +- 48 files changed, 468 insertions(+), 140 deletions(-) create mode 100644 docs/providers/opencode-go.md create mode 100644 src/commands/onboard-auth.config-opencode-go.ts create mode 100644 src/commands/opencode-go-model-default.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 959a2bd0e08..e0a763c1418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman. - iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman. - Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman. +- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc. - macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF. ### Breaking diff --git a/docs/cli/index.md b/docs/cli/index.md index fb68727e44b..cbcd5bff0b5 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -337,7 +337,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) @@ -354,6 +354,7 @@ Options: - `--zai-api-key ` - `--minimax-api-key ` - `--opencode-zen-api-key ` +- `--opencode-go-api-key ` - `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key`) - `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key`) - `--custom-api-key ` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 6dd4c2f9c03..4f3d80b2420 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -86,12 +86,13 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** } ``` -### OpenCode Zen +### OpenCode -- Provider: `opencode` - Auth: `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) -- Example model: `opencode/claude-opus-4-6` -- CLI: `openclaw onboard --auth-choice opencode-zen` +- Zen runtime provider: `opencode` +- Go runtime provider: `opencode-go` +- Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5` +- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go` ```json5 { @@ -104,8 +105,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `google` - Auth: `GEMINI_API_KEY` - Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override) -- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-3.1-flash-lite-preview` -- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`, and bare `google/gemini-3.1-flash-lite` is normalized to `google/gemini-3.1-flash-lite-preview` +- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview` +- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview` - CLI: `openclaw onboard --auth-choice gemini-api-key` ### Google Vertex, Antigravity, and Gemini CLI diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 2ad809d9599..f87eead821c 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -55,8 +55,8 @@ subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`). Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize to `zai/*`. -Provider configuration examples (including OpenCode Zen) live in -[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy). +Provider configuration examples (including OpenCode) live in +[/gateway/configuration](/gateway/configuration#opencode). ## “Model is not allowed” (and why replies stop) diff --git a/docs/docs.json b/docs/docs.json index 8592618cd7d..e6cf5ba382b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -103,6 +103,10 @@ "source": "/opencode", "destination": "/providers/opencode" }, + { + "source": "/opencode-go", + "destination": "/providers/opencode-go" + }, { "source": "/qianfan", "destination": "/providers/qianfan" @@ -1013,8 +1017,7 @@ "tools/browser", "tools/browser-login", "tools/chrome-extension", - "tools/browser-linux-troubleshooting", - "tools/browser-wsl2-windows-remote-cdp-troubleshooting" + "tools/browser-linux-troubleshooting" ] }, { @@ -1112,6 +1115,7 @@ "providers/nvidia", "providers/ollama", "providers/openai", + "providers/opencode-go", "providers/opencode", "providers/openrouter", "providers/qianfan", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 6922234fd2a..1e48f69d6f8 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2079,7 +2079,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct. - + ```json5 { @@ -2092,7 +2092,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct. } ``` -Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Shortcut: `openclaw onboard --auth-choice opencode-zen`. +Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index b46b90520d1..95027906750 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -63,7 +63,7 @@ cat ~/.openclaw/openclaw.json - Health check + restart prompt. - Skills status summary (eligible/missing/blocked). - Config normalization for legacy values. -- OpenCode Zen provider override warnings (`models.providers.opencode`). +- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs). - State integrity and permissions checks (sessions, transcripts, state dir). @@ -134,12 +134,12 @@ Doctor warnings also include account-default guidance for multi-account channels - If two or more `channels..accounts` entries are configured without `channels..defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account. - If `channels..defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs. -### 2b) OpenCode Zen provider overrides +### 2b) OpenCode provider overrides -If you’ve added `models.providers.opencode` (or `opencode-zen`) manually, it -overrides the built-in OpenCode Zen catalog from `@mariozechner/pi-ai`. That can -force every model onto a single API or zero out costs. Doctor warns so you can -remove the override and restore per-model API routing + costs. +If you’ve added `models.providers.opencode`, `opencode-zen`, or `opencode-go` +manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`. +That can force models onto the wrong API or zero out costs. Doctor warns so you +can remove the override and restore per-model API routing + costs. ### 3) Legacy state migrations (disk layout) diff --git a/docs/help/testing.md b/docs/help/testing.md index 6580de4da20..db374bb03da 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -311,11 +311,11 @@ Include at least one image-capable model in `OPENCLAW_LIVE_GATEWAY_MODELS` (Clau If you have keys enabled, we also support testing via: - OpenRouter: `openrouter/...` (hundreds of models; use `openclaw models scan` to find tool+image capable candidates) -- OpenCode Zen: `opencode/...` (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`) +- OpenCode: `opencode/...` for Zen and `opencode-go/...` for Go (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`) More providers you can include in the live matrix (if you have creds/config): -- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot` +- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `opencode-go`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot` - Via `models.providers` (custom endpoints): `minimax` (cloud/API), plus any OpenAI/Anthropic-compatible proxy (LM Studio, vLLM, LiteLLM, etc.) Tip: don’t try to hardcode “all models” in docs. The authoritative list is whatever `discoverModels(...)` returns on your machine + whatever keys are available. diff --git a/docs/providers/index.md b/docs/providers/index.md index a4587213832..50e45c6559b 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -39,7 +39,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi - [NVIDIA](/providers/nvidia) - [Ollama (local models)](/providers/ollama) - [OpenAI (API + Codex)](/providers/openai) -- [OpenCode Zen](/providers/opencode) +- [OpenCode (Zen + Go)](/providers/opencode) - [OpenRouter](/providers/openrouter) - [Qianfan](/providers/qianfan) - [Qwen (OAuth)](/providers/qwen) diff --git a/docs/providers/models.md b/docs/providers/models.md index 7da741f4077..a117d286051 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -32,7 +32,7 @@ model as `provider/model`. - [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) - [Mistral](/providers/mistral) - [Synthetic](/providers/synthetic) -- [OpenCode Zen](/providers/opencode) +- [OpenCode (Zen + Go)](/providers/opencode) - [Z.AI](/providers/zai) - [GLM models](/providers/glm) - [MiniMax](/providers/minimax) diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md new file mode 100644 index 00000000000..4552e916beb --- /dev/null +++ b/docs/providers/opencode-go.md @@ -0,0 +1,45 @@ +--- +summary: "Use the OpenCode Go catalog with the shared OpenCode setup" +read_when: + - You want the OpenCode Go catalog + - You need the runtime model refs for Go-hosted models +title: "OpenCode Go" +--- + +# OpenCode Go + +OpenCode Go is the Go catalog within [OpenCode](/providers/opencode). +It uses the same `OPENCODE_API_KEY` as the Zen catalog, but keeps the runtime +provider id `opencode-go` so upstream per-model routing stays correct. + +## Supported models + +- `opencode-go/kimi-k2.5` +- `opencode-go/glm-5` +- `opencode-go/minimax-m2.5` + +## CLI setup + +```bash +openclaw onboard --auth-choice opencode-go +# or non-interactive +openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY" +``` + +## Config snippet + +```json5 +{ + env: { OPENCODE_API_KEY: "YOUR_API_KEY_HERE" }, // pragma: allowlist secret + agents: { defaults: { model: { primary: "opencode-go/kimi-k2.5" } } }, +} +``` + +## Routing behavior + +OpenClaw handles per-model routing automatically when the model ref uses `opencode-go/...`. + +## Notes + +- Use [OpenCode](/providers/opencode) for the shared onboarding and catalog overview. +- Runtime refs stay explicit: `opencode/...` for Zen, `opencode-go/...` for Go. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index aa0614bff80..bf8d54afc9e 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -1,25 +1,38 @@ --- -summary: "Use OpenCode Zen (curated models) with OpenClaw" +summary: "Use OpenCode Zen and Go catalogs with OpenClaw" read_when: - - You want OpenCode Zen for model access - - You want a curated list of coding-friendly models -title: "OpenCode Zen" + - You want OpenCode-hosted model access + - You want to pick between the Zen and Go catalogs +title: "OpenCode" --- -# OpenCode Zen +# OpenCode -OpenCode Zen is a **curated list of models** recommended by the OpenCode team for coding agents. -It is an optional, hosted model access path that uses an API key and the `opencode` provider. -Zen is currently in beta. +OpenCode exposes two hosted catalogs in OpenClaw: + +- `opencode/...` for the **Zen** catalog +- `opencode-go/...` for the **Go** catalog + +Both catalogs use the same OpenCode API key. OpenClaw keeps the runtime provider ids +split so upstream per-model routing stays correct, but onboarding and docs treat them +as one OpenCode setup. ## CLI setup +### Zen catalog + ```bash openclaw onboard --auth-choice opencode-zen -# or non-interactive openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY" ``` +### Go catalog + +```bash +openclaw onboard --auth-choice opencode-go +openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY" +``` + ## Config snippet ```json5 @@ -29,8 +42,23 @@ openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY" } ``` +## Catalogs + +### Zen + +- Runtime provider: `opencode` +- Example models: `opencode/claude-opus-4-6`, `opencode/gpt-5.2`, `opencode/gemini-3-pro` +- Best when you want the curated OpenCode multi-model proxy + +### Go + +- Runtime provider: `opencode-go` +- Example models: `opencode-go/kimi-k2.5`, `opencode-go/glm-5`, `opencode-go/minimax-m2.5` +- Best when you want the OpenCode-hosted Kimi/GLM/MiniMax lineup + ## Notes - `OPENCODE_ZEN_API_KEY` is also supported. -- You sign in to Zen, add billing details, and copy your API key. -- OpenCode Zen bills per request; check the OpenCode dashboard for details. +- Entering one OpenCode key during onboarding stores credentials for both runtime providers. +- You sign in to OpenCode, add billing details, and copy your API key. +- Billing and catalog availability are managed from the OpenCode dashboard. diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 2e7a43bdecc..d58ab96c83a 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -38,7 +38,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles. - **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider. - - **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth). + - **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog. - **API key**: stores the key for you. - **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`. - More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway) @@ -228,7 +228,7 @@ openclaw onboard --non-interactive \ --gateway-bind loopback ``` - + ```bash openclaw onboard --non-interactive \ --mode local \ @@ -237,6 +237,7 @@ openclaw onboard --non-interactive \ --gateway-port 18789 \ --gateway-bind loopback ``` + Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 14f4a9d5d32..8547f60ac19 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -123,7 +123,7 @@ openclaw onboard --non-interactive \ --gateway-bind loopback ``` - + ```bash openclaw onboard --non-interactive \ --mode local \ @@ -132,6 +132,7 @@ openclaw onboard --non-interactive \ --gateway-port 18789 \ --gateway-bind loopback ``` + Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog. ```bash diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 44f470ea73b..20f99accd8d 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -155,8 +155,8 @@ What you set: Prompts for `XAI_API_KEY` and configures xAI as a model provider. - - Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). + + Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) and lets you choose the Zen or Go catalog. Setup URL: [opencode.ai/auth](https://opencode.ai/auth). diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index 03de7d772cc..059e12d9711 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -81,7 +81,7 @@ export function isModernModelRef(ref: ModelRef): boolean { return false; } - if (provider === "openrouter" || provider === "opencode") { + if (provider === "openrouter" || provider === "opencode" || provider === "opencode-go") { // OpenRouter/opencode are pass-through proxies; accept any model ID // rather than restricting to a static prefix list. return true; diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index 0f387bf3ce3..fbe5a78917d 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -4,6 +4,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], volcengine: ["VOLCANO_ENGINE_API_KEY"], "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 24a881a63cd..a1fc511aaf8 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -412,4 +412,18 @@ describe("getApiKeyForModel", () => { }, ); }); + + it("resolveEnvApiKey('opencode-go') falls back to OPENCODE_ZEN_API_KEY", async () => { + await withEnvAsync( + { + OPENCODE_API_KEY: undefined, + OPENCODE_ZEN_API_KEY: "sk-opencode-zen-fallback", // pragma: allowlist secret + }, + async () => { + const resolved = resolveEnvApiKey("opencode-go"); + expect(resolved?.apiKey).toBe("sk-opencode-zen-fallback"); + expect(resolved?.source).toContain("OPENCODE_ZEN_API_KEY"); + }, + ); + }); }); diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 3c1894bb390..fc52ee2205e 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -313,6 +313,12 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); }); + + it("accepts all opencode-go models without zen exclusions", () => { + expect(isModernModelRef({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true); + expect(isModernModelRef({ provider: "opencode-go", id: "glm-5" })).toBe(true); + expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true); + }); }); describe("resolveForwardCompatModel", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 75df5ed22fa..205c2f1cce0 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -46,6 +46,9 @@ export function normalizeProviderId(provider: string): string { if (normalized === "opencode-zen") { return "opencode"; } + if (normalized === "opencode-go-auth") { + return "opencode-go"; + } if (normalized === "qwen") { return "qwen-portal"; } diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 6386eaef158..81c7a64cb8c 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -9,10 +9,6 @@ import { isAnthropicBillingError, isAnthropicRateLimitError, } from "./live-auth-keys.js"; -import { - isMiniMaxModelNotFoundErrorMessage, - isModelNotFoundErrorMessage, -} from "./live-model-errors.js"; import { isModernModelRef } from "./live-model-filter.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -86,6 +82,35 @@ function isGoogleModelNotFoundError(err: unknown): boolean { return false; } +function isModelNotFoundErrorMessage(raw: string): boolean { + const msg = raw.trim(); + if (!msg) { + return false; + } + if (/\b404\b/.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) { + return true; + } + if (/not_found_error/i.test(msg)) { + return true; + } + if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) { + return true; + } + return false; +} + +describe("isModelNotFoundErrorMessage", () => { + it("matches whitespace-separated not found errors", () => { + expect(isModelNotFoundErrorMessage("404 model not found")).toBe(true); + expect(isModelNotFoundErrorMessage("model: minimax-text-01 not found")).toBe(true); + }); + + it("still matches underscore and hyphen variants", () => { + expect(isModelNotFoundErrorMessage("404 model not_found")).toBe(true); + expect(isModelNotFoundErrorMessage("404 model not-found")).toBe(true); + }); +}); + function isChatGPTUsageLimitErrorMessage(raw: string): boolean { const msg = raw.toLowerCase(); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); @@ -475,11 +500,7 @@ describeLive("live models (profile keys)", () => { if (ok.res.stopReason === "error") { const msg = ok.res.errorMessage ?? ""; - if ( - allowNotFoundSkip && - (isModelNotFoundErrorMessage(msg) || - (model.provider === "minimax" && isMiniMaxModelNotFoundErrorMessage(msg))) - ) { + if (allowNotFoundSkip && isModelNotFoundErrorMessage(msg)) { skipped.push({ model: id, reason: msg }); logProgress(`${progressLabel}: skip (model not found)`); break; @@ -500,7 +521,9 @@ describeLive("live models (profile keys)", () => { } if ( ok.text.length === 0 && - (model.provider === "openrouter" || model.provider === "opencode") + (model.provider === "openrouter" || + model.provider === "opencode" || + model.provider === "opencode-go") ) { skipped.push({ model: id, @@ -563,15 +586,6 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (google model not found)`); break; } - if ( - allowNotFoundSkip && - model.provider === "minimax" && - isMiniMaxModelNotFoundErrorMessage(message) - ) { - skipped.push({ model: id, reason: message }); - logProgress(`${progressLabel}: skip (model not found)`); - break; - } if ( allowNotFoundSkip && model.provider === "minimax" && @@ -592,7 +606,7 @@ describeLive("live models (profile keys)", () => { } if ( allowNotFoundSkip && - model.provider === "opencode" && + (model.provider === "opencode" || model.provider === "opencode-go") && isRateLimitErrorMessage(message) ) { skipped.push({ model: id, reason: message }); diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 5e162c87794..90d2b52ff5a 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -47,6 +47,7 @@ describe("resolveProviderCapabilities", () => { it("flags providers that opt out of OpenAI-compatible turn validation", () => { expect(supportsOpenAiCompatTurnValidation("openrouter")).toBe(false); expect(supportsOpenAiCompatTurnValidation("opencode")).toBe(false); + expect(supportsOpenAiCompatTurnValidation("opencode-go")).toBe(false); expect(supportsOpenAiCompatTurnValidation("moonshot")).toBe(true); }); @@ -63,6 +64,12 @@ describe("resolveProviderCapabilities", () => { modelId: "gemini-2.0-flash", }), ).toBe(true); + expect( + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider: "opencode-go", + modelId: "google/gemini-2.5-pro-preview", + }), + ).toBe(true); expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9"); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 62007b810f8..27aadbcd7d3 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -66,6 +66,11 @@ const PROVIDER_CAPABILITIES: Record> = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + "opencode-go": { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, kilocode: { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts index e235177a309..f22dc10df52 100644 --- a/src/agents/tools/web-fetch.cf-markdown.test.ts +++ b/src/agents/tools/web-fetch.cf-markdown.test.ts @@ -114,7 +114,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => { sandboxed: false, runtimeFirecrawl: { active: false, - apiKeySource: "secretRef", + apiKeySource: "secretRef", // pragma: allowlist secret diagnostics: [], }, }); diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index ad3345a3e06..c416804fa11 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -652,7 +652,7 @@ describe("web_search Perplexity lazy resolution", () => { web: { search: { provider: "gemini", - gemini: { apiKey: "gemini-config-test" }, + gemini: { apiKey: "gemini-config-test" }, // pragma: allowlist secret perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string }, }, }, diff --git a/src/auto-reply/reply/directive-handling.model-picker.ts b/src/auto-reply/reply/directive-handling.model-picker.ts index 0c2bcaf61e6..46c892dab0f 100644 --- a/src/auto-reply/reply/directive-handling.model-picker.ts +++ b/src/auto-reply/reply/directive-handling.model-picker.ts @@ -19,6 +19,7 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [ "zai", "openrouter", "opencode", + "opencode-go", "github-copilot", "groq", "cerebras", diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 6a5bd98aea0..4dd285e63c1 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -168,6 +168,7 @@ export function registerOnboardCommand(program: Command) { togetherApiKey: opts.togetherApiKey as string | undefined, huggingfaceApiKey: opts.huggingfaceApiKey as string | undefined, opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, + opencodeGoApiKey: opts.opencodeGoApiKey as string | undefined, xaiApiKey: opts.xaiApiKey as string | undefined, litellmApiKey: opts.litellmApiKey as string | undefined, volcengineApiKey: opts.volcengineApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index c0c719a70ee..e86f5d5c361 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -41,6 +41,7 @@ describe("buildAuthChoiceOptions", () => { "volcengine-api-key", "byteplus-api-key", "vllm", + "opencode-go", ]) { expect(options.some((opt) => opt.value === value)).toBe(true); } @@ -80,4 +81,16 @@ describe("buildAuthChoiceOptions", () => { expect(chutesGroup).toBeDefined(); expect(chutesGroup?.options.some((opt) => opt.value === "chutes")).toBe(true); }); + + it("groups OpenCode Zen and Go under one OpenCode entry", () => { + const { groups } = buildAuthChoiceGroups({ + store: EMPTY_STORE, + includeSkip: false, + }); + const openCodeGroup = groups.find((group) => group.value === "opencode"); + + expect(openCodeGroup).toBeDefined(); + expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-zen")).toBe(true); + expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-go")).toBe(true); + }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 23e9b80d958..33b3752e585 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -138,10 +138,10 @@ const AUTH_CHOICE_GROUP_DEFS: { choices: ["ai-gateway-api-key"], }, { - value: "opencode-zen", - label: "OpenCode Zen", - hint: "API key", - choices: ["opencode-zen"], + value: "opencode", + label: "OpenCode", + hint: "Shared API key for Zen + Go catalogs", + choices: ["opencode-zen", "opencode-go"], }, { value: "xiaomi", @@ -199,6 +199,8 @@ const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial> = { "venice-api-key": "Privacy-focused inference (uncensored models)", "together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models", "huggingface-api-key": "Inference Providers — OpenAI-compatible chat", + "opencode-zen": "Shared OpenCode key; curated Zen catalog", + "opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog", }; const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = { @@ -206,6 +208,8 @@ const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = "moonshot-api-key-cn": "Kimi API key (.cn)", "kimi-code-api-key": "Kimi Code API key (subscription)", "cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway", + "opencode-zen": "OpenCode Zen catalog", + "opencode-go": "OpenCode Go catalog", }; function buildProviderAuthChoiceOptions(): AuthChoiceOption[] { @@ -289,7 +293,7 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ { value: "apiKey", label: "Anthropic API key" }, { value: "opencode-zen", - label: "OpenCode Zen (multi-model proxy)", + label: "OpenCode Zen catalog", hint: "Claude, GPT, Gemini via opencode.ai/zen", }, { value: "minimax-api", label: "MiniMax M2.5" }, @@ -301,7 +305,7 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ { value: "minimax-api-lightning", label: "MiniMax M2.5 Highspeed", - hint: "Official fast tier", + hint: "Official fast tier (legacy: Lightning)", }, { value: "qianfan-api-key", label: "Qianfan API key" }, { diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 046a2e24893..9e7419f7fda 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -34,6 +34,8 @@ import { applyMoonshotConfigCn, applyMoonshotProviderConfig, applyMoonshotProviderConfigCn, + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, applySyntheticConfig, @@ -68,6 +70,7 @@ import { setKimiCodingApiKey, setMistralApiKey, setMoonshotApiKey, + setOpencodeGoApiKey, setOpencodeZenApiKey, setSyntheticApiKey, setTogetherApiKey, @@ -84,6 +87,7 @@ import { setModelStudioApiKey, } from "./onboard-auth.js"; import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; @@ -104,6 +108,7 @@ const API_KEY_TOKEN_PROVIDER_AUTH_CHOICE: Record = { huggingface: "huggingface-api-key", mistral: "mistral-api-key", opencode: "opencode-zen", + "opencode-go": "opencode-go", kilocode: "kilocode-api-key", qianfan: "qianfan-api-key", }; @@ -240,20 +245,40 @@ const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial> = { "minimax-api-lightning": "minimax", minimax: "lmstudio", "opencode-zen": "opencode", + "opencode-go": "opencode-go", "xai-api-key": "xai", "litellm-api-key": "litellm", "qwen-portal": "qwen-portal", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 0431e558dac..200471971a2 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -498,6 +498,15 @@ describe("applyAuthChoice", () => { profileId: "opencode:default", provider: "opencode", modelPrefix: "opencode/", + extraProfiles: ["opencode-go:default"], + }, + { + authChoice: "opencode-go", + tokenProvider: "opencode-go", + profileId: "opencode-go:default", + provider: "opencode-go", + modelPrefix: "opencode-go/", + extraProfiles: ["opencode:default"], }, { authChoice: "together-api-key", @@ -522,7 +531,7 @@ describe("applyAuthChoice", () => { }, ] as const)( "uses opts token for $authChoice without prompting", - async ({ authChoice, tokenProvider, profileId, provider, modelPrefix }) => { + async ({ authChoice, tokenProvider, profileId, provider, modelPrefix, extraProfiles }) => { await setupTempState(); const text = vi.fn(); @@ -554,6 +563,9 @@ describe("applyAuthChoice", () => { ), ).toBe(true); expect((await readAuthProfile(profileId))?.key).toBe(token); + for (const extraProfile of extraProfiles ?? []) { + expect((await readAuthProfile(extraProfile))?.key).toBe(token); + } }, ); @@ -805,14 +817,15 @@ describe("applyAuthChoice", () => { it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => { const scenarios: Array<{ - authChoice: "xai-api-key" | "opencode-zen"; + authChoice: "xai-api-key" | "opencode-zen" | "opencode-go"; token: string; promptMessage: string; existingPrimary: string; expectedOverride: string; profileId?: string; profileProvider?: string; - expectProviderConfigUndefined?: "opencode-zen"; + extraProfileId?: string; + expectProviderConfigUndefined?: "opencode" | "opencode-go" | "opencode-zen"; agentId?: string; }> = [ { @@ -828,10 +841,24 @@ describe("applyAuthChoice", () => { { authChoice: "opencode-zen", token: "sk-opencode-zen-test", - promptMessage: "Enter OpenCode Zen API key", + promptMessage: "Enter OpenCode API key", existingPrimary: "anthropic/claude-opus-4-5", expectedOverride: "opencode/claude-opus-4-6", - expectProviderConfigUndefined: "opencode-zen", + profileId: "opencode:default", + profileProvider: "opencode", + extraProfileId: "opencode-go:default", + expectProviderConfigUndefined: "opencode", + }, + { + authChoice: "opencode-go", + token: "sk-opencode-go-test", + promptMessage: "Enter OpenCode API key", + existingPrimary: "anthropic/claude-opus-4-5", + expectedOverride: "opencode-go/kimi-k2.5", + profileId: "opencode-go:default", + profileProvider: "opencode-go", + extraProfileId: "opencode:default", + expectProviderConfigUndefined: "opencode-go", }, ]; for (const scenario of scenarios) { @@ -863,6 +890,9 @@ describe("applyAuthChoice", () => { }); expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token); } + if (scenario.extraProfileId) { + expect((await readAuthProfile(scenario.extraProfileId))?.key).toBe(scenario.token); + } if (scenario.expectProviderConfigUndefined) { expect( result.config.models?.providers?.[scenario.expectProviderConfigUndefined], diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts index dea3fa1b3f2..994bac5f863 100644 --- a/src/commands/doctor-config-analysis.ts +++ b/src/commands/doctor-config-analysis.ts @@ -105,18 +105,22 @@ export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void { if (providers["opencode-zen"]) { overrides.push("opencode-zen"); } + if (providers["opencode-go"]) { + overrides.push("opencode-go"); + } if (overrides.length === 0) { return; } const lines = overrides.flatMap((id) => { + const providerLabel = id === "opencode-go" ? "OpenCode Go" : "OpenCode Zen"; const providerEntry = providers[id]; const api = isRecord(providerEntry) && typeof providerEntry.api === "string" ? providerEntry.api : undefined; return [ - `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, + `- models.providers.${id} is set; this overrides the built-in ${providerLabel} catalog.`, api ? `- models.providers.${id}.api=${api}` : null, ].filter((line): line is string => Boolean(line)); }); @@ -124,7 +128,7 @@ export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void { lines.push( "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", ); - note(lines.join("\n"), "OpenCode Zen"); + note(lines.join("\n"), "OpenCode"); } export function noteIncludeConfinementWarning(snapshot: { diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 69c9da9d579..68d865996d2 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -41,6 +41,10 @@ describe("doctor command", () => { api: "openai-completions", baseUrl: "https://opencode.ai/zen/v1", }, + "opencode-go": { + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", + }, }, }, }, @@ -53,7 +57,9 @@ describe("doctor command", () => { const warned = note.mock.calls.some( ([message, title]) => - title === "OpenCode Zen" && String(message).includes("models.providers.opencode"), + title === "OpenCode" && + String(message).includes("models.providers.opencode") && + String(message).includes("models.providers.opencode-go"), ); expect(warned).toBe(true); }); diff --git a/src/commands/onboard-auth.config-opencode-go.ts b/src/commands/onboard-auth.config-opencode-go.ts new file mode 100644 index 00000000000..25be5ffa18f --- /dev/null +++ b/src/commands/onboard-auth.config-opencode-go.ts @@ -0,0 +1,36 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; + +const OPENCODE_GO_ALIAS_DEFAULTS: Record = { + "opencode-go/kimi-k2.5": "Kimi", + "opencode-go/glm-5": "GLM", + "opencode-go/minimax-m2.5": "MiniMax", +}; + +export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + // Use the built-in opencode-go provider from pi-ai; only seed allowlist aliases. + const models = { ...cfg.agents?.defaults?.models }; + for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) { + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? alias, + }; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyOpencodeGoProviderConfig(cfg); + return applyAgentDefaultModelPrimary(next, OPENCODE_GO_DEFAULT_MODEL_REF); +} diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index 5ff2c57461d..e844ac501c2 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -3,6 +3,7 @@ import { setByteplusApiKey, setCloudflareAiGatewayConfig, setMoonshotApiKey, + setOpencodeZenApiKey, setOpenaiApiKey, setVolcengineApiKey, } from "./onboard-auth.js"; @@ -22,6 +23,7 @@ describe("onboard auth credentials secret refs", () => { "CLOUDFLARE_AI_GATEWAY_API_KEY", "VOLCANO_ENGINE_API_KEY", "BYTEPLUS_API_KEY", + "OPENCODE_API_KEY", ]); afterEach(async () => { @@ -207,4 +209,25 @@ describe("onboard auth credentials secret refs", () => { }); expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined(); }); + + it("stores shared OpenCode credentials for both runtime providers", async () => { + const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-opencode-"); + lifecycle.setStateDir(env.stateDir); + process.env.OPENCODE_API_KEY = "sk-opencode-env"; // pragma: allowlist secret + + await setOpencodeZenApiKey("sk-opencode-env", env.agentDir, { + secretInputMode: "ref", // pragma: allowlist secret + }); + + const parsed = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(env.agentDir); + + expect(parsed.profiles?.["opencode:default"]).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, + }); + expect(parsed.profiles?.["opencode-go:default"]).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, + }); + }); }); diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index c83861b5685..92e1170b010 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -433,11 +433,30 @@ export async function setOpencodeZenApiKey( agentDir?: string, options?: ApiKeyStorageOptions, ) { - upsertAuthProfile({ - profileId: "opencode:default", - credential: buildApiKeyCredential("opencode", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); + await setSharedOpencodeApiKey(key, agentDir, options); +} + +export async function setOpencodeGoApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + await setSharedOpencodeApiKey(key, agentDir, options); +} + +async function setSharedOpencodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + const resolvedAgentDir = resolveAuthAgentDir(agentDir); + for (const provider of ["opencode", "opencode-go"] as const) { + upsertAuthProfile({ + profileId: `${provider}:default`, + credential: buildApiKeyCredential(provider, key, undefined, options), + agentDir: resolvedAgentDir, + }); + } } export async function setTogetherApiKey( diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index a79eb1d970a..fa2c9f4f10d 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -16,6 +16,8 @@ import { applyMistralProviderConfig, applyMinimaxApiConfig, applyMinimaxApiProviderConfig, + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, applyOpenrouterConfig, @@ -675,6 +677,11 @@ describe("allowlist provider helpers", () => { modelRef: "opencode/claude-opus-4-6", alias: "My Opus", }, + { + applyConfig: applyOpencodeGoProviderConfig, + modelRef: "opencode-go/kimi-k2.5", + alias: "Kimi", + }, { applyConfig: applyOpenrouterProviderConfig, modelRef: OPENROUTER_DEFAULT_MODEL_REF, @@ -729,6 +736,10 @@ describe("default-model config helpers", () => { applyConfig: applyOpencodeZenConfig, primaryModel: "opencode/claude-opus-4-6", }, + { + applyConfig: applyOpencodeGoConfig, + primaryModel: "opencode-go/kimi-k2.5", + }, { applyConfig: applyOpenrouterConfig, primaryModel: OPENROUTER_DEFAULT_MODEL_REF, diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 22946567fae..cda460b6c19 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -60,6 +60,10 @@ export { applyOpencodeZenConfig, applyOpencodeZenProviderConfig, } from "./onboard-auth.config-opencode.js"; +export { + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, +} from "./onboard-auth.config-opencode-go.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, KILOCODE_DEFAULT_MODEL_REF, @@ -77,6 +81,7 @@ export { setMinimaxApiKey, setMistralApiKey, setMoonshotApiKey, + setOpencodeGoApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, setSyntheticApiKey, diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 3f5ccee1755..9606b70259f 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -42,11 +42,6 @@ let upsertAuthProfile: typeof import("../agents/auth-profiles.js").upsertAuthPro type ProviderAuthConfigSnapshot = { auth?: { profiles?: Record }; agents?: { defaults?: { model?: { primary?: string } } }; - talk?: { - provider?: string; - apiKey?: string | { source?: string; id?: string }; - providers?: Record; - }; models?: { providers?: Record< string, @@ -362,38 +357,6 @@ describe("onboard (non-interactive): provider auth", () => { }); }); - it("does not persist talk fallback secrets when OpenAI ref onboarding starts from an empty config", async () => { - await withOnboardEnv("openclaw-onboard-openai-ref-no-talk-leak-", async (env) => { - await withEnvAsync( - { - OPENAI_API_KEY: "sk-openai-env-key", // pragma: allowlist secret - ELEVENLABS_API_KEY: "elevenlabs-env-key", // pragma: allowlist secret - }, - async () => { - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "openai-api-key", - secretInputMode: "ref", // pragma: allowlist secret - }); - - expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL); - expect(cfg.talk).toBeUndefined(); - - const store = ensureAuthProfileStore(); - const profile = store.profiles["openai:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.key).toBeUndefined(); - expect(profile.keyRef).toEqual({ - source: "env", - provider: "default", - id: "OPENAI_API_KEY", - }); - } - }, - ); - }); - }); - it.each([ { name: "anthropic", @@ -479,7 +442,7 @@ describe("onboard (non-interactive): provider auth", () => { }, ); - it("stores the detected env alias as keyRef for opencode ref mode", async () => { + it("stores the detected env alias as keyRef for both OpenCode runtime providers", async () => { await withOnboardEnv("openclaw-onboard-ref-opencode-alias-", async ({ runtime }) => { await withEnvAsync( { @@ -494,15 +457,17 @@ describe("onboard (non-interactive): provider auth", () => { }); const store = ensureAuthProfileStore(); - const profile = store.profiles["opencode:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.key).toBeUndefined(); - expect(profile.keyRef).toEqual({ - source: "env", - provider: "default", - id: "OPENCODE_ZEN_API_KEY", - }); + for (const profileId of ["opencode:default", "opencode-go:default"]) { + const profile = store.profiles[profileId]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.key).toBeUndefined(); + expect(profile.keyRef).toEqual({ + source: "env", + provider: "default", + id: "OPENCODE_ZEN_API_KEY", + }); + } } }, ); diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index a49be3ad2c8..212bb9dd890 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -27,6 +27,7 @@ type AuthChoiceFlagOptions = Pick< | "xiaomiApiKey" | "minimaxApiKey" | "opencodeZenApiKey" + | "opencodeGoApiKey" | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 9739f57ce2e..7636e64d6d6 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -23,6 +23,7 @@ import { applyMinimaxConfig, applyMoonshotConfig, applyMoonshotConfigCn, + applyOpencodeGoConfig, applyOpencodeZenConfig, applyOpenrouterConfig, applySyntheticConfig, @@ -48,6 +49,7 @@ import { setMinimaxApiKey, setMoonshotApiKey, setOpenaiApiKey, + setOpencodeGoApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, setSyntheticApiKey, @@ -926,6 +928,33 @@ export async function applyNonInteractiveAuthChoice(params: { return applyOpencodeZenConfig(nextConfig); } + if (authChoice === "opencode-go") { + const resolved = await resolveApiKey({ + provider: "opencode-go", + cfg: baseConfig, + flagValue: opts.opencodeGoApiKey, + flagName: "--opencode-go-api-key", + envVar: "OPENCODE_API_KEY", + runtime, + }); + if (!resolved) { + return null; + } + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setOpencodeGoApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "opencode-go:default", + provider: "opencode-go", + mode: "api_key", + }); + return applyOpencodeGoConfig(nextConfig); + } + if (authChoice === "together-api-key") { const resolved = await resolveApiKey({ provider: "together", diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index 43c552f99fb..7610727097f 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -20,6 +20,7 @@ type OnboardProviderAuthOptionKey = keyof Pick< | "togetherApiKey" | "huggingfaceApiKey" | "opencodeZenApiKey" + | "opencodeGoApiKey" | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" @@ -163,7 +164,14 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray authChoice: "opencode-zen", cliFlag: "--opencode-zen-api-key", cliOption: "--opencode-zen-api-key ", - description: "OpenCode Zen API key", + description: "OpenCode API key (Zen catalog)", + }, + { + optionKey: "opencodeGoApiKey", + authChoice: "opencode-go", + cliFlag: "--opencode-go-api-key", + cliOption: "--opencode-go-api-key ", + description: "OpenCode API key (Go catalog)", }, { optionKey: "xaiApiKey", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 44f4660321e..bb8bf150a0b 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -41,6 +41,7 @@ export type AuthChoice = | "minimax-api-lightning" | "minimax-portal" | "opencode-zen" + | "opencode-go" | "github-copilot" | "copilot-proxy" | "qwen-portal" @@ -68,7 +69,7 @@ export type AuthChoiceGroupId = | "moonshot" | "zai" | "xiaomi" - | "opencode-zen" + | "opencode" | "minimax" | "synthetic" | "venice" @@ -134,6 +135,7 @@ export type OnboardOptions = { togetherApiKey?: string; huggingfaceApiKey?: string; opencodeZenApiKey?: string; + opencodeGoApiKey?: string; xaiApiKey?: string; volcengineApiKey?: string; byteplusApiKey?: string; diff --git a/src/commands/opencode-go-model-default.ts b/src/commands/opencode-go-model-default.ts new file mode 100644 index 00000000000..c959f23ff2e --- /dev/null +++ b/src/commands/opencode-go-model-default.ts @@ -0,0 +1,11 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { applyAgentDefaultPrimaryModel } from "./model-default.js"; + +export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5"; + +export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF }); +} diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 866fa6c33f7..88900893376 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -15,6 +15,7 @@ export const PROVIDER_ENV_VARS: Record = { litellm: ["LITELLM_API_KEY"], "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], together: ["TOGETHER_API_KEY"], huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], qianfan: ["QIANFAN_API_KEY"], diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index b8c1e679ba6..b4484095188 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -184,7 +184,7 @@ describe("runtime web tools resolution", () => { }, }), env: { - BRAVE_API_KEY_REF: "brave-runtime-key", + BRAVE_API_KEY_REF: "brave-runtime-key", // pragma: allowlist secret }, }); @@ -225,7 +225,7 @@ describe("runtime web tools resolution", () => { }, }), env: { - GEMINI_API_KEY_REF: "gemini-runtime-key", + GEMINI_API_KEY_REF: "gemini-runtime-key", // pragma: allowlist secret }, }); @@ -260,7 +260,7 @@ describe("runtime web tools resolution", () => { }, }), env: { - GEMINI_API_KEY_REF: "gemini-runtime-key", + GEMINI_API_KEY_REF: "gemini-runtime-key", // pragma: allowlist secret }, }); @@ -397,7 +397,7 @@ describe("runtime web tools resolution", () => { }, }), env: { - FIRECRAWL_API_KEY: "firecrawl-fallback-key", + FIRECRAWL_API_KEY: "firecrawl-fallback-key", // pragma: allowlist secret }, }); diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 004af2bdfe2..d888b36e8ab 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -18,7 +18,7 @@ const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; -type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; +type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret type RuntimeWebProviderSource = "configured" | "auto-detect" | "none"; export type RuntimeWebDiagnosticCode =