diff --git a/.changeset/console-transition.md b/.changeset/console-transition.md new file mode 100644 index 0000000..844178c --- /dev/null +++ b/.changeset/console-transition.md @@ -0,0 +1,9 @@ +--- +"opencode-bridge-copilot-chat": minor +--- + +Transition OpenCode Zen into OpenCode Console, matching upstream's consolidation of the Zen gateway into the Console. + +- The `opencodezen` provider group is removed; former Zen entries now live in the OpenCode Console group against the same `https://opencode.ai/zen/v1` gateway. Console and Go both accept a service-account API key and the Console device-code flow, mirroring upstream's two sign-in methods. Stored Zen API keys, `opencode.defaultMode` values, inline-suggestion gateway values, and usage history migrate automatically. +- Console model catalogs: service-account keys load the shared public `/models` catalog with free-only filtering, while device-code profiles keep the organization-scoped `/api/config` discovery. +- Refreshed live model catalogs with new Console models (`jev-1.13`, `jev-1.13-free`, `fledge-alpha-free`) and new Go models (`deepseek-v4-pro`, `mimo-v2.6-flash`, `mimo-v2.6-pro`, `gpt-6-luna`, `grok-4.7`, `qwen3.8-max/flash`), with supplemental metadata for discovery-only ids not yet in models.dev. diff --git a/README.md b/README.md index 8b7d278..25186e5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

OpenCode for Copilot Chat

-

Use OpenCode Zen, OpenCode Go, and OpenCode Console models directly from the GitHub Copilot Chat model picker in Visual Studio Code.

+

Use OpenCode Console and OpenCode Go models directly from the GitHub Copilot Chat model picker in Visual Studio Code.

Latest GitHub release @@ -12,16 +12,16 @@ MIT license

-This native VS Code `LanguageModelChatProvider` registers Zen, Go, and Console as separate provider groups and streams their responses into Copilot Chat without a local proxy. VS Code 1.125 exposes the provider-entry configuration through the built-in Language Models UI; the packaged extension does not enable proposed APIs. +This native VS Code `LanguageModelChatProvider` registers Console and Go as separate provider groups and streams their responses into Copilot Chat without a local proxy. VS Code 1.125 exposes the provider-entry configuration through the built-in Language Models UI; the packaged extension does not enable proposed APIs. > [!IMPORTANT] > Visual Studio Marketplace publishing is temporarily paused. Install and update the extension by sideloading the VSIX attached to the [latest GitHub release](https://github.com/grikomsn/opencode-copilot-chat/releases/latest). ## Highlights -- Separate OpenCode Zen, Go, and Console model groups -- API-key and Console device-code authentication in VS Code Secret Storage -- Credential-scoped live discovery, with six-hour persisted models.dev enrichment for Zen and Go +- Separate OpenCode Console and Go model groups +- Service-account API keys and Console device-code authentication in VS Code Secret Storage +- Credential-scoped live discovery, with six-hour persisted models.dev enrichment for Console and Go - Streaming text, reasoning, image inputs, and agent-mode tool calls - Model-specific Thinking Effort and Qwen thinking-budget controls - Bounded gateway retries and context-aware token limits @@ -31,15 +31,15 @@ This native VS Code `LanguageModelChatProvider` registers Zen, Go, and Console a 1. Download the `.vsix` asset from the [latest GitHub release](https://github.com/grikomsn/opencode-copilot-chat/releases/latest). You need VS Code 1.125 or newer and GitHub Copilot Chat. 2. In VS Code, open **Extensions**, select the **…** menu, choose **Install from VSIX…**, and select the downloaded file. Alternatively, run `code --install-extension ./opencode-bridge-copilot-chat-.vsix --force`. -3. For Zen or Go, open **Manage Language Models**, choose **Add Models**, select the provider, name the entry, and paste its API key. -4. For Console, run **OpenCode: Add Console Account**, choose a profile ID, then add an OpenCode Console entry with the same ID in **Manage Language Models**. +3. For Console or Go, open **Manage Language Models**, choose **Add Models**, select the provider, name the entry, and paste its service-account API key (Console entries can omit the key and use a device-code profile instead). +4. For Console device-code sign-in, run **OpenCode: Add Console Account**, choose a profile ID, then add an OpenCode Console entry with the same ID in **Manage Language Models**. Go can also sign in with the same Console device-code flow from the Manage Connection menu. 5. Repeat either flow for another account or key, then enable the models you want in Copilot Chat. VS Code cannot automatically update a sideloaded build from GitHub. To update, download the newest VSIX from the latest release and repeat step 2; the newer build replaces the installed version while preserving the extension's stored settings and credentials. While Marketplace publishing is paused, the extension checks GitHub Releases at most once every 24 hours. When a newer VSIX is available, it shows a one-time notification with a link to the release and sideloading guidance. -Composer controls override workspace defaults; ordered effort controls default to High, binary controls default On, and Qwen defaults Auto. Zen and Go use each entry's authenticated live catalog, while Console shows only models enabled for that profile's selected organization. Click the OpenCode status-bar item to inspect tokens for the most recently used entry. +Composer controls override workspace defaults; ordered effort controls default to High, binary controls default On, and Qwen defaults Auto. Console and Go use each entry's authenticated live catalog, while a Console device-code profile shows only models enabled for that profile's selected organization. Click the OpenCode status-bar item to inspect tokens for the most recently used entry. ## Documentation diff --git a/docs/models.md b/docs/models.md index 9ffb006..f17fd24 100644 --- a/docs/models.md +++ b/docs/models.md @@ -4,14 +4,17 @@ The extension discovers the catalog available to each configured provider entry from that gateway's live `models` endpoint: `https://opencode.ai/zen/v1/models` -for Zen and `https://opencode.ai/zen/go/v1/models` for Go. Live responses +for Console and `https://opencode.ai/zen/go/v1/models` for Go. (The paths retain +the historical `zen` segment upstream; the former Zen gateway is now Console's +pay-as-you-go inference service.) Live responses provide names, context windows, maximum output lengths, reasoning support and effort options, image input, and tool-calling support used by Copilot Chat. -Zen free-tier entries can be filtered to cost-free models only. +Console free-tier entries can be filtered to cost-free models only. -Console entries skip public discovery: models come from the selected +Console device-code profiles skip public discovery: models come from the selected organization's configuration on the Console server and never show public or -other-organization models. +other-organization models. Console service-account key entries use the public +discovery catalog like Go. Fields the live responses omit are enriched from the canonical `opencode` and `opencode-go` providers in a six-hour models.dev snapshot @@ -19,24 +22,25 @@ Fields the live responses omit are enriched from the canonical `opencode` and is returned immediately while refresh runs and remains available during models.dev outages. Models served by discovery before models.dev catalogs them use a small supplemental entry — currently the Go-only ids `hy3-preview`, -`deepseek-flash`, `minimax-m2.5`, `kimi-k2.5`, `glm-5.1`, `glm-5`, and -`qwen3.5-plus` — +`deepseek-flash`, `minimax-m2.5`, `kimi-k2.5`, `glm-5.1`, `glm-5`, +`qwen3.5-plus`, `mimo-v2-pro`, `mimo-v2-omni`, and `omen-alpha`, plus the +Console-only ids `jev-1.13` and `jev-1.13-free` — which is superseded by the canonical upstream entry once it lands. Discovery also serves legacy alias ids (currently the Go id `deepseek-flash` for `deepseek-v4.1-flash`); the alias is hidden whenever the canonical id appears in the same discovery response. -Successful Zen and Go catalog results are cached per entry (mode, free-only +Successful Console and Go catalog results are cached per entry (mode, free-only scope, and a fingerprint of the credential) for up to 24 hours in `globalState`. When discovery fails or no key is configured, the extension falls back to the cached catalog and then to a bundled snapshot: | Gateway | Model | Context | | --- | --- | ---: | -| Zen | DeepSeek V4 Flash Free | 200K | -| Zen | Nemotron 3 Ultra Free | 1M | -| Zen | Big Pickle | 200K | -| Zen | Kimi K2.5 | 256K | +| Console | DeepSeek V4 Flash Free | 200K | +| Console | Nemotron 3 Ultra Free | 1M | +| Console | Big Pickle | 200K | +| Console | Kimi K2.5 | 256K | | Go | Kimi K2.6 | 256K | | Go | DeepSeek V4 Flash | 1M | | Go | MiniMax-M3 | 1M | diff --git a/docs/security.md b/docs/security.md index 9fc2bc1..7ab42bb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Credential handling -Zen and Go API keys added through **Manage Language Models** are marked as secret provider configuration, so VS Code stores every entry securely. The extension places only a short SHA-256-derived reference in model metadata and local usage state. The older command-managed Zen and Go defaults remain in Secret Storage for migration and development workflows. +Console and Go API keys added through **Manage Language Models** are marked as secret provider configuration, so VS Code stores every entry securely. The extension places only a short SHA-256-derived reference in model metadata and local usage state. The older command-managed Console (formerly Zen) and Go defaults remain in Secret Storage for migration and development workflows. Console access and refresh tokens are obtained only through an explicit device-code sign-in initiated in VS Code and stored per named profile in VS Code Secret Storage, with separate refresh locks and organization selection. The extension does not read credentials or sessions from OpenCode or other applications on the local machine. @@ -8,4 +8,4 @@ The output channel records only status and metadata. It does not record API keys ## Inline completions -When `opencode.inlineSuggestions` is enabled, each suggestion request sends a bounded window of the current document (a fixed number of lines before the cursor and a bounded suffix after it) plus the stored Zen or Go API key to the OpenCode gateway's chat-completions endpoint. Upstream error bodies are never surfaced or logged because they can echo prompt context. Suggestion text flows only into the editor's ghost text; document context, prompts, and keys are never written to logs or storage. The feature is disabled by default and can be turned off at any time. +When `opencode.inlineSuggestions` is enabled, each suggestion request sends a bounded window of the current document (a fixed number of lines before the cursor and a bounded suffix after it) plus the stored Console or Go API key to the OpenCode gateway's chat-completions endpoint. Upstream error bodies are never surfaced or logged because they can echo prompt context. Suggestion text flows only into the editor's ghost text; document context, prompts, and keys are never written to logs or storage. The feature is disabled by default and can be turned off at any time. diff --git a/docs/setup.md b/docs/setup.md index 6080f51..52ad41a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -2,19 +2,20 @@ Open **Manage Language Models** and use **Add Models** for each OpenCode entry you want: -- Zen and Go accept an API key directly in the native provider form. VS Code stores the field as a secret. Add another named entry to use another account or key. -- Console first requires **OpenCode: Add Console Account**. Choose a profile ID, complete device authorization and organization selection, then enter the same profile ID when adding the native Console provider entry. Leaving the profile field empty preserves the legacy `default` Console account. +- Console and Go accept a service-account API key directly in the native provider form. VS Code stores the field as a secret. Add another named entry to use another account or key. Create keys in the OpenCode Console at https://opencode.ai/console. +- Console also supports device-code sign-in: run **OpenCode: Add Console Account**, choose a profile ID, complete device authorization and organization selection, then enter the same profile ID when adding the native Console provider entry (leaving the API key empty). An entry API key takes precedence over its profile. Leaving the profile field empty preserves the legacy `default` Console account. +- Go additionally accepts the Console device-code flow from the **OpenCode Go: Manage Connection** menu; Go subscriptions are managed in the Console, so both providers share one account flow. - **OpenCode: Select Console Profile for Usage and Management** chooses the profile targeted by those commands. VS Code restores it after restart; it does not change the account attached to a native model entry. -Zen, Go, and Console remain separate providers, and each provider can now have multiple named entries. `opencode.defaultMode` only chooses the provider targeted by generic legacy connection, refresh, and test commands; it does not hide other entries. Console model discovery is profile- and organization-scoped and is read from `/api/config` with `x-org-id`; models marked `disabled` in that response are excluded. +Console and Go remain separate providers, and each provider can now have multiple named entries. `opencode.defaultMode` only chooses the provider targeted by generic legacy connection, refresh, and test commands; it does not hide other entries. A service-account key loads the shared public `/models` catalog; a Console device-code profile loads organization-scoped model discovery from `/api/config` with `x-org-id`, and models marked `disabled` in that response are excluded. After device sign-in, VS Code Secret Storage is authoritative. Token refreshes, organization changes, and sign-out operate only on the session managed by the extension. The extension does not read credentials or sessions from local OpenCode data. If the organization config is unavailable, no Console models are shown. Reasoning-capable models show a **Thinking** or **Thinking Effort** submenu in the Copilot Chat model picker. The extension derives the choices from OpenCode's live `reasoning_options` metadata when available and maps the selected value to the request format required by that model family. Qwen models can also expose a separate thinking-token budget control. -Inline code suggestions (experimental) are off by default. Enable `opencode.inlineSuggestions` to receive ghost-text completions while typing, with fill-in-the-middle emulated over the gateway's chat-completions endpoint using FIM delimiter tokens. Pick the gateway with `opencode.inlineSuggestionsGateway` (`go` by default, `zen` also available) and the model with `opencode.inlineSuggestionsModel`; choose a model with a genuine non-thinking mode — measured on the live gateway, `qwen3.7-plus` (Go) and `qwen3.6-plus` (Zen) return zero hidden reasoning, while reasoning-first families stay too slow for ghost text. Requests send a bounded window of document context (10 lines before the cursor, 300 characters after, both configurable), are debounced 300ms, time out after 3s, and are aborted on the next keystroke. Suggestions never appear in the Copilot Chat prompt box unless `opencode.inlineSuggestionsChatInput` is enabled. +Inline code suggestions (experimental) are off by default. Enable `opencode.inlineSuggestions` to receive ghost-text completions while typing, with fill-in-the-middle emulated over the gateway's chat-completions endpoint using FIM delimiter tokens. Pick the gateway with `opencode.inlineSuggestionsGateway` (`go` by default, `console` also available; the legacy `zen` value is treated as console) and the model with `opencode.inlineSuggestionsModel`; choose a model with a genuine non-thinking mode — measured on the live gateway, `qwen3.7-plus` (Go) and `qwen3.6-plus` (Console) return zero hidden reasoning, while reasoning-first families stay too slow for ghost text. Requests send a bounded window of document context (10 lines before the cursor, 300 characters after, both configurable), are debounced 300ms, time out after 3s, and are aborted on the next keystroke. Suggestions never appear in the Copilot Chat prompt box unless `opencode.inlineSuggestionsChatInput` is enabled. -**OpenCode: Set Inline Suggestions Model** (also in the Manage Connection menu) lists compatible models ordered cheap-and-fast first, each with a measured badge (for example "★ recommended · measured 1.4s TTFB"). Picking a Zen-only model while the gateway is Go switches `opencode.inlineSuggestionsGateway` too, and a "Use a custom model id…" entry keeps any undocumented gateway model reachable. The command only writes settings, so changes apply on the next keystroke without a reload. +**OpenCode: Set Inline Suggestions Model** (also in the Manage Connection menu) lists compatible models ordered cheap-and-fast first, each with a measured badge (for example "★ recommended · measured 1.4s TTFB"). Picking a Console-only model while the gateway is Go switches `opencode.inlineSuggestionsGateway` too, and a "Use a custom model id…" entry keeps any undocumented gateway model reachable. The command only writes settings, so changes apply on the next keystroke without a reload. -For Zen and Go, a signed-in refresh treats the account's `/models` response as authoritative and fills omitted fields from the canonical `opencode` or `opencode-go` provider in models.dev. Zen and Go share one six-hour, stale-while-revalidate metadata snapshot in VS Code `globalState`. The last successful live result is cached for up to 24 hours under a credential-scoped key; Console configuration is never persisted in that public cache. Request limits reserve context headroom, and only rejected optional parameters or transient 502–504/router failures are retried. +For Console and Go, a signed-in refresh treats the account's `/models` response as authoritative and fills omitted fields from the canonical `opencode` or `opencode-go` provider in models.dev. Console and Go share one six-hour, stale-while-revalidate metadata snapshot in VS Code `globalState`. The last successful live result is cached for up to 24 hours under a credential-scoped key; organization-scoped Console configuration is never persisted in that public cache. Request limits reserve context headroom, and only rejected optional parameters or transient 502–504/router failures are retried. -Use **OpenCode: Show Usage** or click the status-bar indicator to inspect inference tokens for the most recently used provider entry. Usage snapshots and catalogs remain isolated by API-key fingerprint or Console profile. The generic **OpenCode: Test Connection (Legacy Key / Active Console Profile)** and **OpenCode: Refresh Models (Legacy Key / Active Console Profile)** commands intentionally target only the command-managed Zen/Go key or active Console profile. `opencode.defaultMode` chooses the provider family targeted by these commands; when it is Console, **OpenCode: Select Console Profile for Usage and Management** chooses the profile. Native Zen/Go entries are discovered and refreshed by VS Code through **Manage Language Models** and the model picker. `opencode.catalogCacheMinutes` controls normal live-catalog freshness, while `opencode.requestTimeoutSeconds` and `opencode.streamIdleTimeoutSeconds` bound total request time and stalled streams separately. Secret-safe request metadata can be enabled with `opencode.debugLogging`. +Use **OpenCode: Show Usage** or click the status-bar indicator to inspect inference tokens for the most recently used provider entry. Usage snapshots and catalogs remain isolated by API-key fingerprint or Console profile. The generic **OpenCode: Test Connection (Legacy Key / Active Console Profile)** and **OpenCode: Refresh Models (Legacy Key / Active Console Profile)** commands intentionally target only the command-managed Console/Go key or active Console profile. `opencode.defaultMode` chooses the provider family targeted by these commands (legacy `zen` values are treated as console); when it is Console, **OpenCode: Select Console Profile for Usage and Management** chooses the profile. Native entries are discovered and refreshed by VS Code through **Manage Language Models** and the model picker. `opencode.catalogCacheMinutes` controls normal live-catalog freshness, while `opencode.requestTimeoutSeconds` and `opencode.streamIdleTimeoutSeconds` bound total request time and stalled streams separately. Secret-safe request metadata can be enabled with `opencode.debugLogging`. diff --git a/package.json b/package.json index 5504394..4f1875f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-bridge-copilot-chat", "displayName": "OpenCode for Copilot Chat", - "description": "Use OpenCode Zen, Go, and Console models in GitHub Copilot Chat.", + "description": "Use OpenCode Console and Go models in GitHub Copilot Chat.", "version": "0.7.3", "publisher": "grikomsn", "license": "MIT", @@ -24,7 +24,6 @@ ], "keywords": [ "opencode", - "zen", "opencode-go", "console", "copilot", @@ -36,7 +35,6 @@ ], "activationEvents": [ "onStartupFinished", - "onLanguageModelChatProvider:opencodezen", "onLanguageModelChatProvider:opencodego", "onLanguageModelChatProvider:opencodeconsole" ], @@ -52,17 +50,13 @@ "title": "OpenCode: Manage Connection" }, { - "command": "opencodeCopilot.manageZen", - "title": "OpenCode Zen: Manage Connection" + "command": "opencodeCopilot.manageConsole", + "title": "OpenCode Console: Manage Connection" }, { "command": "opencodeCopilot.manageGo", "title": "OpenCode Go: Manage Connection" }, - { - "command": "opencodeCopilot.manageConsole", - "title": "OpenCode Console: Manage Connection" - }, { "command": "opencodeCopilot.addConsoleAccount", "title": "OpenCode: Add Console Account" @@ -94,22 +88,20 @@ "opencode.defaultMode": { "type": "string", "enum": [ - "zen", - "go", - "console" + "console", + "go" ], - "default": "zen", + "default": "console", "enumDescriptions": [ - "Target OpenCode Zen for generic connection, refresh, and test commands", - "Target OpenCode Go for generic connection, refresh, and test commands", - "Target OpenCode Console for generic connection, refresh, and test commands" + "Target OpenCode Console for generic connection, refresh, and test commands", + "Target OpenCode Go for generic connection, refresh, and test commands" ], - "description": "OpenCode group targeted by generic connection, refresh, and test commands. Zen, Go, and Console models remain visible as separate provider groups." + "description": "OpenCode group targeted by generic connection, refresh, and test commands. Console and Go models remain visible as separate provider groups. The legacy zen value is treated as console." }, "opencode.freeOnly": { "type": "boolean", "default": true, - "description": "Show only zero-cost OpenCode Zen models in the model picker." + "description": "Show only zero-cost OpenCode Console models in the model picker." }, "opencode.inlineSuggestions": { "type": "boolean", @@ -120,19 +112,19 @@ "type": "string", "enum": [ "go", - "zen" + "console" ], "default": "go", "enumDescriptions": [ "Send inline completion requests through OpenCode Go", - "Send inline completion requests through OpenCode Zen" + "Send inline completion requests through OpenCode Console" ], - "description": "OpenCode gateway used for inline completion requests, independent of the chat provider in use." + "description": "OpenCode gateway used for inline completion requests, independent of the chat provider in use. The legacy zen value is treated as console." }, "opencode.inlineSuggestionsModel": { "type": "string", "default": "qwen3.7-plus", - "description": "Model id used for inline completions. Choose a model with a genuine non-thinking mode, such as qwen3.7-plus (Go) or qwen3.6-plus (Zen); reasoning-first models are too slow for ghost text." + "description": "Model id used for inline completions. Choose a model with a genuine non-thinking mode, such as qwen3.7-plus (Go) or qwen3.6-plus (Console); reasoning-first models are too slow for ghost text." }, "opencode.inlineSuggestionsChatInput": { "type": "boolean", @@ -371,19 +363,23 @@ }, "languageModelChatProviders": [ { - "vendor": "opencodezen", - "displayName": "OpenCode Zen", + "vendor": "opencodeconsole", + "displayName": "OpenCode Console", "configuration": { "type": "object", - "required": [ - "apiKey" - ], "properties": { "apiKey": { "type": "string", - "title": "OpenCode Zen API Key", - "description": "API key for this OpenCode Zen provider entry.", + "title": "OpenCode Service Account API Key", + "description": "Service-account API key for this OpenCode Console provider entry. Takes precedence over device-code profiles.", "secret": true + }, + "profile": { + "type": "string", + "title": "OpenCode Console Profile", + "description": "Profile ID created with OpenCode: Add Console Account (device-code sign-in). Used when no API key is set.", + "default": "default", + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" } } } @@ -400,27 +396,11 @@ "apiKey": { "type": "string", "title": "OpenCode Go API Key", - "description": "API key for this OpenCode Go provider entry.", + "description": "Service-account API key for this OpenCode Go provider entry.", "secret": true } } } - }, - { - "vendor": "opencodeconsole", - "displayName": "OpenCode Console", - "configuration": { - "type": "object", - "properties": { - "profile": { - "type": "string", - "title": "OpenCode Console Profile", - "description": "Profile ID created with OpenCode: Add Console Account.", - "default": "default", - "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" - } - } - } } ] }, diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index c9cce15..92a8899 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -37,11 +37,54 @@ test("opens Console device verification at opencode.ai/console/device", async () assert.equal(device.userCode, "ABCD-EFGH"); }); -test("stores Zen and Go keys separately", async () => { +test("stores Console and Go keys separately", async () => { const auth = new OpenCodeAuth(new Secrets() as never); - await auth.setApiKey("zen", "zen-key"); + await auth.setApiKey("console", "console-key"); await auth.setApiKey("go", "go-key"); - assert.deepEqual(await auth.getApiKeys(), { zen: "zen-key", go: "go-key" }); + assert.deepEqual(await auth.getApiKeys(), { console: "console-key", go: "go-key" }); +}); + +test("migrates the legacy Zen service-account key to Console", async () => { + const secrets = new Secrets(); + await secrets.store("opencode.apiKeys.v1", JSON.stringify({ zen: "legacy-zen-key", go: "go-key" })); + const auth = new OpenCodeAuth(secrets as never); + assert.deepEqual(await auth.getApiKeys(), { console: "legacy-zen-key", go: "go-key" }); + // The migration is persisted so later reads are stable. + assert.equal(await secrets.get("opencode.apiKeys.v1"), JSON.stringify({ console: "legacy-zen-key", go: "go-key" })); +}); + +test("prefers the Console service-account key over the device-flow session", async () => { + const secrets = new Secrets(); + await secrets.store("opencode.apiKeys.v1", JSON.stringify({ console: "console-key" })); + await secrets.store("opencode.consoleSession.v1", JSON.stringify({ + mode: "console", server: "https://example.test", accessToken: "access", refreshToken: "refresh", + expiresAt: Date.now() + 3_600_000, accountId: "account", email: "user@example.com", orgs: [], + })); + const auth = new OpenCodeAuth(secrets as never); + const credential = await auth.getCredential("console"); + assert.equal(credential?.token, "console-key"); + assert.equal(credential?.server, undefined); + // Force-refresh still resolves through the session (used on 401); the + // device-flow token endpoint is fetched without a live network in tests. + const session = await auth.getConsoleSession(); + assert.equal(session?.accessToken, "access"); + assert.equal(session?.server, "https://example.test"); +}); + +test("Go device-code sign-in resolves a Console session credential", async () => { + const fetcher: typeof fetch = async (input) => { + const url = String(input); + if (url.endsWith("/auth/device/token")) return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); + if (url.endsWith("/api/user")) return Response.json({ id: "account", email: "user@example.com" }); + return Response.json([{ id: "org-1", name: "Alpha" }]); + }; + const auth = new OpenCodeAuth(new Secrets() as never, fetcher, () => 1_000, async () => undefined); + const device: DeviceCode = { deviceCode: "device", userCode: "ABCD", verificationUrl: "https://example.test", expiresAt: 100_000, intervalMs: 1, server: "https://example.test" }; + await auth.completeDeviceSignIn(device); + // Go requests authenticate with the Console session token. + const credential = await auth.getCredential("go"); + assert.equal(credential?.mode, "go"); + assert.equal(credential?.token, "access"); }); test("completes device flow and selects an organization", async () => { diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 8efbb9e..9202f1c 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -6,6 +6,12 @@ const CONSOLE_SESSION_KEY = "opencode.consoleSession.v1"; const CONSOLE_PROFILES_KEY = "opencode.consoleProfiles.v1"; export const DEFAULT_CONSOLE_PROFILE = "default"; +/** + * Legacy secret blob written by versions that treated Zen as its own provider. + * It stored `{ zen, go }`; the `zen` entry carried a Console service-account + * API key and is read as the console slot on load. + */ + export function normalizeConsoleProfile(value: string): string { const profile = value.trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(profile)) { @@ -19,7 +25,7 @@ function consoleSessionKey(profile: string): string { } export interface ApiKeys { - zen?: string; + console?: string; go?: string; } @@ -77,32 +83,46 @@ export class OpenCodeAuth { async getApiKeys(): Promise { const raw = await this.secrets.get(API_KEYS_KEY); if (!raw) return {}; + let parsed: (ApiKeys & { zen?: unknown }) | undefined; try { - const value = JSON.parse(raw) as ApiKeys; - return { - ...(typeof value.zen === "string" && value.zen.trim() ? { zen: value.zen } : {}), - ...(typeof value.go === "string" && value.go.trim() ? { go: value.go } : {}), - }; + parsed = JSON.parse(raw) as ApiKeys & { zen?: unknown }; } catch { return {}; } + // Versions before the Console transition stored the Console + // service-account key under the legacy `zen` slot of this same blob. + // Rewrite the blob once so persisted state matches the current shape. + const hasLegacy = typeof parsed.zen === "string" && parsed.zen.trim() && !parsed.console; + if (hasLegacy) { + const migrated: ApiKeys = { + console: (parsed.zen as string).trim(), + ...(typeof parsed.go === "string" && parsed.go.trim() ? { go: parsed.go.trim() } : {}), + }; + await this.secrets.store(API_KEYS_KEY, JSON.stringify(migrated)); + return migrated; + } + return { + ...(typeof parsed.console === "string" && parsed.console.trim() ? { console: parsed.console } : {}), + ...(typeof parsed.go === "string" && parsed.go.trim() ? { go: parsed.go } : {}), + }; } - async setApiKey(mode: "zen" | "go", value: string): Promise { + async setApiKey(mode: OpenCodeMode, value: string): Promise { const keys = await this.getApiKeys(); keys[mode] = value.trim(); await this.secrets.store(API_KEYS_KEY, JSON.stringify(keys)); } - async clearApiKey(mode: "zen" | "go"): Promise { + async clearApiKey(mode: OpenCodeMode): Promise { const keys = await this.getApiKeys(); delete keys[mode]; await this.secrets.store(API_KEYS_KEY, JSON.stringify(keys)); } async hasCredential(mode: OpenCodeMode, profile = DEFAULT_CONSOLE_PROFILE): Promise { - if (mode === "console") return Boolean(await this.getConsoleSession(profile)); - return Boolean((await this.getApiKeys())[mode]); + const keys = await this.getApiKeys(); + if (keys[mode]) return true; + return Boolean(await this.getConsoleSession(profile)); } async getCredential( @@ -110,10 +130,12 @@ export class OpenCodeAuth { forceRefresh = false, profile = DEFAULT_CONSOLE_PROFILE, ): Promise { - if (mode !== "console") { - const token = (await this.getApiKeys())[mode]; - return token ? { mode, token } : undefined; - } + // A stored service-account API key takes precedence over the device-flow + // session, mirroring the upstream key method's precedence. Both Console + // and Go requests may authenticate with either credential; device sign-in + // for either mode stores a shared Console session. + const apiKey = (await this.getApiKeys())[mode]; + if (apiKey && !forceRefresh) return { mode, token: apiKey }; const normalized = normalizeConsoleProfile(profile); const session = await this.getConsoleSession(normalized); if (!session) return undefined; @@ -234,10 +256,11 @@ export class OpenCodeAuth { } async signOut(mode: OpenCodeMode, profile = DEFAULT_CONSOLE_PROFILE): Promise { - if (mode !== "console") { - await this.clearApiKey(mode); - return; - } + // Signing out clears the mode's service-account key. For Console it also + // clears the device-flow session so the next sign-in starts clean; Go + // device sign-ins share that session, so it is intentionally preserved. + await this.clearApiKey(mode); + if (mode !== "console") return; const normalized = normalizeConsoleProfile(profile); this.invalidateProfile(normalized); await this.mutateSession(normalized, async () => { diff --git a/src/autocomplete/config.test.ts b/src/autocomplete/config.test.ts index c473a8f..01ed38c 100644 --- a/src/autocomplete/config.test.ts +++ b/src/autocomplete/config.test.ts @@ -48,7 +48,8 @@ test("keeps the documented inline-suggestion defaults", () => { }); test("parses the inline gateway defensively", () => { - assert.equal(parseInlineGateway("zen"), "zen"); + assert.equal(parseInlineGateway("zen"), "console"); + assert.equal(parseInlineGateway("console"), "console"); assert.equal(parseInlineGateway("go"), "go"); assert.equal(parseInlineGateway(undefined), "go"); assert.equal(parseInlineGateway("enterprise"), "go"); diff --git a/src/autocomplete/config.ts b/src/autocomplete/config.ts index 8ea07aa..b08ddb3 100644 --- a/src/autocomplete/config.ts +++ b/src/autocomplete/config.ts @@ -18,8 +18,13 @@ export const INLINE_MAX_TOKENS_SETTING = "inlineSuggestionsMaxTokens"; export const INLINE_PREFIX_LINES_SETTING = "inlineSuggestionsPrefixLines"; export const INLINE_SUFFIX_CHARS_SETTING = "inlineSuggestionsSuffixChars"; -/** Gateway used for completion requests; independent of the chat provider. */ -export type InlineGateway = "zen" | "go"; +/** + * Gateway used for completion requests; independent of the chat provider. + * The legacy `zen` value is preserved in settings and resolved to the Console + * gateway (the former Zen gateway is now Console's pay-as-you-go inference). + */ +export type InlineGateway = "console" | "go"; +export type InlineGatewaySetting = InlineGateway | "zen"; export const DEFAULT_INLINE_GATEWAY: InlineGateway = "go"; /** Live-measured non-thinking default (Go gateway, `enable_thinking: false`). */ @@ -33,7 +38,7 @@ export const DEFAULT_INLINE_SUGGESTIONS_CHAT_INPUT = false; export const INLINE_SUGGESTIONS_DOC = "Provide experimental ghost-text inline completions while typing."; export const INLINE_GATEWAY_DESCRIPTION = "OpenCode gateway used for inline completion requests, independent of the chat provider in use."; -export const INLINE_MODEL_DESCRIPTION = "Model id used for inline completions. Use a model that supports a genuine non-thinking mode, such as qwen3.7-plus (Go) or qwen3.6-plus (Zen)."; +export const INLINE_MODEL_DESCRIPTION = "Model id used for inline completions. Use a model that supports a genuine non-thinking mode, such as qwen3.7-plus (Go) or qwen3.6-plus (Console)."; export const INLINE_CHAT_INPUT_DESCRIPTION = "Also offer inline completions inside the Copilot Chat prompt box."; export const INLINE_DEBOUNCE_DESCRIPTION = "Debounce window in milliseconds between typing and an inline completion request."; export const INLINE_TIMEOUT_DESCRIPTION = "Per-request timeout in milliseconds for inline completions."; @@ -42,10 +47,15 @@ export const INLINE_PREFIX_LINES_DESCRIPTION = "Lines of context sent before the export const INLINE_SUFFIX_CHARS_DESCRIPTION = "Characters of context sent after the cursor."; export function parseInlineGateway(value: unknown): InlineGateway { - return value === "zen" ? "zen" : DEFAULT_INLINE_GATEWAY; + return value === "go" || value === "console" || value === "zen" ? resolveInlineGateway(value) : DEFAULT_INLINE_GATEWAY; +} + +/** Maps the persisted setting value (including legacy `zen`) to a gateway. */ +export function resolveInlineGateway(value: InlineGatewaySetting): InlineGateway { + return value === "zen" ? "console" : value; } export function clampNumber(value: unknown, fallback: number, min: number, max: number): number { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.min(max, Math.max(min, Math.floor(value))); -} \ No newline at end of file +} diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index b4f3d01..b8f7550 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -100,4 +100,4 @@ export function registerInlineCompletions(context: vscode.ExtensionContext, deps const registration = vscode.languages.registerInlineCompletionItemProvider({ pattern: "**" }, provider); context.subscriptions.push(registration, provider); return registration; -} \ No newline at end of file +} diff --git a/src/autocomplete/models.test.ts b/src/autocomplete/models.test.ts index ddd8d77..18269eb 100644 --- a/src/autocomplete/models.test.ts +++ b/src/autocomplete/models.test.ts @@ -4,7 +4,7 @@ import { DEFAULT_INLINE_GATEWAY, DEFAULT_INLINE_MODEL } from "./config"; import { inlineModelChoicesForGateway, INLINE_MODEL_CANDIDATES } from "./models"; test("both gateways expose candidates with the recommended default first", () => { - for (const gateway of ["go", "zen"] as const) { + for (const gateway of ["go", "console"] as const) { const candidates = INLINE_MODEL_CANDIDATES[gateway]; assert.ok(candidates.length >= 2, `${gateway} should list alternatives`); assert.equal(candidates[0]?.badge.includes("★ recommended"), true, `${gateway} should lead with a recommendation`); @@ -14,7 +14,7 @@ test("both gateways expose candidates with the recommended default first", () => test("default gateway candidate matches the configured default gateway", () => { assert.equal(INLINE_MODEL_CANDIDATES[DEFAULT_INLINE_GATEWAY][0]?.id, "qwen3.7-plus"); - assert.equal(INLINE_MODEL_CANDIDATES.zen[0]?.id, "qwen3.6-plus"); + assert.equal(INLINE_MODEL_CANDIDATES.console[0]?.id, "qwen3.6-plus"); }); test("every candidate carries a badge and a rationale", () => { diff --git a/src/autocomplete/models.ts b/src/autocomplete/models.ts index 11a8e26..aa14435 100644 --- a/src/autocomplete/models.ts +++ b/src/autocomplete/models.ts @@ -78,17 +78,17 @@ const ZEN_CANDIDATES: readonly InlineModelCandidate[] = [ { id: "qwen3.6-plus", badge: "★ recommended · measured 1.4s TTFB", - detail: "Zero hidden reasoning with enable_thinking: false; pay-as-you-go Zen balance required.", + detail: "Zero hidden reasoning with enable_thinking: false; pay-as-you-go Console balance required.", }, { id: "kimi-k2.5", badge: "measured 1.2s TTFB", - detail: "Zero hidden reasoning with thinking disabled; measured faster than the default on Zen.", + detail: "Zero hidden reasoning with thinking disabled; measured faster than the default on Console.", }, { id: "qwen3.5-plus", badge: "measured 1.8s TTFB", - detail: "The original fork default; zero hidden reasoning, pay-as-you-go Zen balance required.", + detail: "The original fork default; zero hidden reasoning, pay-as-you-go Console balance required.", }, { id: "glm-5.2", @@ -99,7 +99,7 @@ const ZEN_CANDIDATES: readonly InlineModelCandidate[] = [ export const INLINE_MODEL_CANDIDATES: Readonly> = { go: GO_CANDIDATES, - zen: ZEN_CANDIDATES, + console: ZEN_CANDIDATES, }; export interface InlineModelChoice { diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 5e37c22..2895447 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { DEFAULT_INLINE_GATEWAY, DEFAULT_INLINE_MODEL, INLINE_SUGGESTIONS_GATEWAY_SETTING, INLINE_SUGGESTIONS_MODEL_SETTING, parseInlineGateway } from "../autocomplete/config"; +import { DEFAULT_INLINE_GATEWAY, DEFAULT_INLINE_MODEL, INLINE_SUGGESTIONS_GATEWAY_SETTING, INLINE_SUGGESTIONS_MODEL_SETTING, parseInlineGateway, type InlineGateway } from "../autocomplete/config"; import { inlineModelChoicesForGateway } from "../autocomplete/models"; import { DEFAULT_CONSOLE_PROFILE, normalizeConsoleProfile, OpenCodeAuth, type ConsoleOrg } from "../auth/auth"; import { messageOf } from "../errors"; @@ -18,9 +18,8 @@ export function registerCommands( ): vscode.Disposable[] { return [ vscode.commands.registerCommand("opencodeCopilot.manage", () => manage(auth, providers, output)), - vscode.commands.registerCommand("opencodeCopilot.manageZen", () => manage(auth, providers, output, "zen")), - vscode.commands.registerCommand("opencodeCopilot.manageGo", () => manage(auth, providers, output, "go")), vscode.commands.registerCommand("opencodeCopilot.manageConsole", () => manage(auth, providers, output, "console")), + vscode.commands.registerCommand("opencodeCopilot.manageGo", () => manage(auth, providers, output, "go")), vscode.commands.registerCommand("opencodeCopilot.addConsoleAccount", () => addConsoleAccount(auth, providers.console, output)), vscode.commands.registerCommand("opencodeCopilot.selectConsoleProfile", () => selectConsoleProfile(auth, providers.console)), vscode.commands.registerCommand("opencodeCopilot.setInlineSuggestionsModel", () => setInlineSuggestionsModel()), @@ -49,9 +48,10 @@ async function manage(auth: OpenCodeAuth, providers: OpenCodeProviders, output: { label: "$(output) Show OpenCode logs", action: "logs" }, ] : [ - { label: "$(key) Sign in with OpenCode Zen API key", action: "zen" }, - { label: "$(key) Sign in with OpenCode Go API key", action: "go" }, - { label: "$(device-mobile) Sign in with OpenCode Console device code", action: "console" }, + { label: "$(key) Sign in with an OpenCode service-account API key", action: "console" }, + { label: "$(device-mobile) Sign in with an OpenCode Console account (device code)", action: "console-device" }, + { label: "$(key) Sign in with an OpenCode Go service-account API key", action: "go" }, + { label: "$(device-mobile) Sign in with an OpenCode Go Console account (device code)", action: "go-device" }, { label: "$(add) Add named Console account", action: "addConsole" }, { label: "$(account) Select Console profile for usage and management", action: "profile" }, { label: "$(output) Show OpenCode logs", action: "logs" }, @@ -68,25 +68,32 @@ async function manage(auth: OpenCodeAuth, providers: OpenCodeProviders, output: else if (picked.action === "addConsole") await addConsoleAccount(auth, providers.console, output); else if (picked.action === "signout") await signOut(auth, provider, mode, profile); else if (picked.action === "switch") await chooseModeAndSignIn(auth, providers, output); - else if (picked.action === "zen" || picked.action === "go") await signInWithApiKey(auth, providers[picked.action], picked.action); - else if (picked.action === "console") await signInWithConsole(auth, providers.console, output, profile); + else if (picked.action === "console") await signInWithApiKey(auth, providers.console, "console"); + else if (picked.action === "console-device") await signInWithConsole(auth, providers.console, output, profile); + else if (picked.action === "go") await signInWithApiKey(auth, providers.go, "go"); + else if (picked.action === "go-device") await signInWithConsoleForMode(auth, providers.go, output, "go"); } async function chooseModeAndSignIn(auth: OpenCodeAuth, providers: OpenCodeProviders, output: vscode.OutputChannel): Promise { const picked = await vscode.window.showQuickPick([ - { label: "OpenCode Zen API key", mode: "zen" as const }, - { label: "OpenCode Go API key", mode: "go" as const }, - { label: "OpenCode Console device code", mode: "console" as const }, + { label: "OpenCode Console service-account API key", mode: "console" as const, device: false }, + { label: "OpenCode Console account (device code)", mode: "console" as const, device: true }, + { label: "OpenCode Go service-account API key", mode: "go" as const, device: false }, + { label: "OpenCode Go Console account (device code)", mode: "go" as const, device: true }, ], { title: "Choose an OpenCode credential mode" }); if (!picked) return; - if (picked.mode === "console") await signInWithConsole(auth, providers.console, output); - else await signInWithApiKey(auth, providers[picked.mode], picked.mode); + if (picked.device) { + if (picked.mode === "go") await signInWithConsoleForMode(auth, providers.go, output, "go"); + else await signInWithConsole(auth, providers.console, output); + } else { + await signInWithApiKey(auth, providers[picked.mode], picked.mode); + } } -async function signInWithApiKey(auth: OpenCodeAuth, provider: OpenCodeProvider, mode: "zen" | "go"): Promise { +async function signInWithApiKey(auth: OpenCodeAuth, provider: OpenCodeProvider, mode: OpenCodeMode): Promise { const key = await vscode.window.showInputBox({ - title: `OpenCode ${label(mode)} API key`, - prompt: `Paste your OpenCode ${label(mode)} API key`, + title: `OpenCode ${label(mode)} service-account API key`, + prompt: `Paste your OpenCode ${label(mode)} service-account API key (create one in the OpenCode Console)`, password: true, ignoreFocusOut: true, validateInput: (value) => value.trim() ? undefined : "An API key is required", @@ -138,6 +145,47 @@ async function signInWithConsole( } } +/** + * Device-code sign-in that targets the Go gateway: the OpenCode Console + * account authenticates the user, then the Go provider refreshes models with + * the Console session token. Go subscriptions and keys are managed in the + * Console, so both providers share one account flow. + */ +async function signInWithConsoleForMode( + auth: OpenCodeAuth, + provider: OpenCodeProvider, + output: vscode.OutputChannel, + mode: "go", +): Promise { + let device: Awaited> | undefined; + try { + device = await auth.requestDeviceCode(); + await vscode.env.clipboard.writeText(device.userCode); + const opened = await vscode.env.openExternal(vscode.Uri.parse(device.verificationUrl)); + if (!opened) throw new Error(`Open ${device.verificationUrl} and enter code ${device.userCode}`); + vscode.window.showInformationMessage(`OpenCode Console code ${device.userCode} copied to the clipboard.`); + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Waiting for OpenCode Console sign-in…", cancellable: true }, + async (_progress, cancellation) => { + const controller = new AbortController(); + const listener = cancellation.onCancellationRequested(() => controller.abort()); + try { await auth.completeDeviceSignIn(device!, controller.signal); } + finally { listener.dispose(); } + }, + ); + const session = await auth.getConsoleSession(); + if (!session) throw new Error("OpenCode Console sign-in completed without a stored session"); + await chooseOrganization(auth, session.orgs); + await setMode("go"); + const models = await provider.refreshModels(); + const selected = await auth.getConsoleSession(); + vscode.window.showInformationMessage(`OpenCode ${label(mode)} connected with the Console account${selected?.orgName ? ` ${selected.orgName}` : ""}. Found ${models.length} models.`); + } catch (error) { + output.appendLine(`[console] ${messageOf(error)}`); + vscode.window.showErrorMessage(`OpenCode ${label(mode)} sign-in failed: ${messageOf(error)}`); + } +} + async function addConsoleAccount(auth: OpenCodeAuth, provider: OpenCodeProvider, output: vscode.OutputChannel): Promise { const value = await vscode.window.showInputBox({ title: "Add OpenCode Console account", @@ -224,7 +272,7 @@ async function refreshModels(provider: OpenCodeProvider): Promise { } interface InlineModelPickItem extends vscode.QuickPickItem { - readonly action?: { readonly id: string; readonly gateway: "zen" | "go" } | "custom"; + readonly action?: { readonly id: string; readonly gateway: InlineGateway } | "custom"; } async function setInlineSuggestionsModel(): Promise { @@ -243,7 +291,7 @@ async function setInlineSuggestionsModel(): Promise { { label: "$(pencil) Use a custom model id…", detail: "Enter any model id available on the selected gateway.", action: "custom" }, ], { title: "OpenCode — Set Inline Suggestions Model", - placeHolder: `Current: ${current} (via OpenCode ${gateway === "zen" ? "Zen" : "Go"})`, + placeHolder: `Current: ${current} (via OpenCode ${gateway === "console" ? "Console" : "Go"})`, }); if (!picked) return; if (picked.action === "custom") { @@ -262,7 +310,7 @@ async function setInlineSuggestionsModel(): Promise { let suffix = ""; if (picked.action.gateway !== gateway) { await configuration.update(INLINE_SUGGESTIONS_GATEWAY_SETTING, picked.action.gateway, vscode.ConfigurationTarget.Global); - suffix = ` and switched the gateway to OpenCode ${picked.action.gateway === "zen" ? "Zen" : "Go"}`; + suffix = ` and switched the gateway to OpenCode ${picked.action.gateway === "console" ? "Console" : "Go"}`; } void vscode.window.showInformationMessage(`OpenCode inline suggestions model set to ${picked.action.id}${suffix}. Applies on the next keystroke.`); } @@ -305,7 +353,7 @@ function toUsageQuickPickItem(row: UsageDisplayRow): UsageQuickPickItem { } async function diagnostics(auth: OpenCodeAuth, providers: OpenCodeProviders): Promise { - const modes: readonly OpenCodeMode[] = ["zen", "go", "console"]; + const modes: readonly OpenCodeMode[] = ["console", "go"]; const modelGroups = await Promise.all(modes.map(async (mode) => ({ mode, models: await vscode.lm.selectChatModels({ vendor: OPENCODE_PROVIDER_DEFINITIONS[mode].vendor }), @@ -313,12 +361,14 @@ async function diagnostics(auth: OpenCodeAuth, providers: OpenCodeProviders): Pr const profiles = await auth.listConsoleProfiles(); const activeConsole = providers.console.getActiveProfile(); const session = await auth.getConsoleSession(activeConsole); + const apiKeys = await auth.getApiKeys(); const lines = [ "# OpenCode for Copilot Chat diagnostics", "", `- VS Code: ${vscode.version}`, `- Console profiles: ${profiles.length ? profiles.join(", ") : "none"}`, `- Active Console profile: ${activeConsole}`, `- Active Console session: ${session ? "present" : "missing"}`, - `- Console organization selected: ${session?.orgId ? "yes" : "no"}`, "", + `- Console organization selected: ${session?.orgId ? "yes" : "no"}`, + `- Service-account API keys stored: ${[apiKeys.console ? "console" : undefined, apiKeys.go ? "go" : undefined].filter(Boolean).join(", ") || "none"}`, "", ...(await Promise.all(modelGroups.map(async ({ mode, models }) => [ `## OpenCode ${label(mode)}`, "", @@ -335,12 +385,13 @@ async function diagnostics(auth: OpenCodeAuth, providers: OpenCodeProviders): Pr } function currentMode(): OpenCodeMode { - const value = vscode.workspace.getConfiguration("opencode").get("defaultMode", "zen"); - return value === "go" || value === "console" ? value : "zen"; + const value = vscode.workspace.getConfiguration("opencode").get("defaultMode", "console"); + // Legacy `zen` persisted values map onto the Console provider. + return value === "go" ? "go" : "console"; } async function setMode(mode: OpenCodeMode): Promise { await vscode.workspace.getConfiguration("opencode").update("defaultMode", mode, vscode.ConfigurationTarget.Global); } -function label(mode: OpenCodeMode): string { return mode === "go" ? "Go" : mode === "console" ? "Console" : "Zen"; } +function label(mode: OpenCodeMode): string { return mode === "go" ? "Go" : "Console"; } diff --git a/src/extension.ts b/src/extension.ts index 6d4f418..7f146ab 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,8 +24,14 @@ export function activate(context: vscode.ExtensionContext): void { const auth = new OpenCodeAuth(context.secrets); const version = context.extension.packageJSON.version as string; const userAgent = `opencode-copilot-chat/${version} VSCode/${vscode.version}`; - const initialUsage = context.globalState.get>>(USAGE_STATE_KEY) - ?? { "zen:legacy": context.globalState.get(LEGACY_USAGE_STATE_KEY) ?? {} }; + // Versions before the Console transition tracked usage under `zen:*` scopes; + // those snapshots are migrated to `console:*` and shown for the Console + // provider's legacy credential scope. + const legacyUsage = context.globalState.get>>(USAGE_STATE_KEY); + const migratedUsage = legacyUsage + ? Object.fromEntries(Object.entries(legacyUsage).map(([scope, usage]) => [migrateUsageScope(scope), usage])) + : { "console:legacy": context.globalState.get(LEGACY_USAGE_STATE_KEY) ?? {} }; + const initialUsage = migratedUsage; const activeConsoleProfile = activeConsoleProfileFromState(context.globalState.get(ACTIVE_CONSOLE_PROFILE_STATE_KEY)); const metadata = new ModelsDevMetadata(context.globalState); const providers = Object.fromEntries(Object.values(OPENCODE_PROVIDER_DEFINITIONS).map((definition) => [ @@ -43,9 +49,9 @@ export function activate(context: vscode.ExtensionContext): void { const usageStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90); usageStatus.name = "OpenCode usage"; usageStatus.command = "opencodeCopilot.showUsage"; - renderUsageStatus(usageStatus, providers.zen.getUsageSnapshot()); + renderUsageStatus(usageStatus, providers.console.getUsageSnapshot()); updateUsageStatusVisibility(usageStatus); - let activeUsageProvider = providers.zen; + let activeUsageProvider = providers.console; context.subscriptions.push( output, usageStatus, @@ -64,7 +70,7 @@ export function activate(context: vscode.ExtensionContext): void { )); })), vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("opencode.freeOnly")) providers.zen.fireDidChange(); + if (event.affectsConfiguration("opencode.freeOnly")) providers.console.fireDidChange(); if (event.affectsConfiguration("opencode.reasoningEffort") || event.affectsConfiguration("opencode.thinking") || event.affectsConfiguration("opencode.maxOutputTokens") @@ -125,3 +131,8 @@ function updateUsageStatusVisibility(item: vscode.StatusBarItem): void { if (vscode.workspace.getConfiguration("opencode").get("showUsageStatusBar", true)) item.show(); else item.hide(); } + +/** Maps pre-Console usage scopes (`zen:*`) onto Console equivalents. */ +function migrateUsageScope(scope: string): string { + return scope === "zen:legacy" ? "console:legacy" : scope.replace(/^zen:/, "console:"); +} diff --git a/src/models/catalog.test.ts b/src/models/catalog.test.ts index 1c4a043..1a77bc1 100644 --- a/src/models/catalog.test.ts +++ b/src/models/catalog.test.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { catalogScope, ModelCatalog, modelsFromProvider } from "./catalog"; -test("filters deprecated and paid Zen models when free-only is enabled", () => { - const models = modelsFromProvider("zen", "opencode", { api: "https://example.test/v1" }, { +test("filters deprecated and paid Console models when free-only is enabled", () => { + const models = modelsFromProvider("console", "opencode", { api: "https://example.test/v1" }, { free: { id: "free", name: "Free", limit: { context: 100, output: 50 }, reasoning: true, tool_call: true, cost: { input: 0 } }, paid: { id: "paid", limit: { context: 100, output: 50 }, cost: { input: 1 } }, old: { id: "old", status: "deprecated", limit: { context: 100, output: 50 } }, @@ -12,7 +12,7 @@ test("filters deprecated and paid Zen models when free-only is enabled", () => { }); test("filters internal smoke-test ids leaked into discovery", () => { - const models = modelsFromProvider("zen", "opencode", { api: "https://example.test/v1" }, { + const models = modelsFromProvider("console", "opencode", { api: "https://example.test/v1" }, { test: { id: "test" }, "test-novita-dsf4.1": { id: "test-novita-dsf4.1" }, "test-model": { id: "test-model" }, @@ -48,7 +48,7 @@ test("uses authenticated live models and enriches fields from models.dev", async live: { id: "live", name: "Metadata Name", limit: { context: 1000, output: 50 }, reasoning: true, tool_call: true }, stale: { id: "stale" }, } } }))); - const models = await catalog.refresh("zen", { mode: "zen", token: "token" }, false); + const models = await catalog.refresh("console", { mode: "console", token: "token" }, false); assert.deepEqual(models.map((model) => model.id), ["live"]); assert.deepEqual({ name: models[0].name, context: models[0].contextLength, output: models[0].maxOutputTokens, tools: models[0].toolCalling }, { name: "Live Name", @@ -70,7 +70,7 @@ test("uses the live public catalog without credentials and excludes stale metada removed: { id: "removed", name: "Removed" }, } } }); }); - const models = await catalog.refresh("zen", undefined, false); + const models = await catalog.refresh("console", undefined, false); assert.deepEqual(models.map((model) => model.id), ["current"]); assert.equal(models[0].name, "Current"); assert.equal(authorization, null); @@ -136,9 +136,9 @@ test("restores a recent authenticated catalog cache when refresh fails", async ( const fetcher = async (input: RequestInfo | URL) => String(input).endsWith("/models") ? new Response(JSON.stringify({ data: [{ id: "cached" }] })) : new Response(JSON.stringify({ opencode: { models: { cached: { id: "cached" } } } })); - await new ModelCatalog(fetcher, cache).refresh("zen", { mode: "zen", token: "token" }, false); + await new ModelCatalog(fetcher, cache).refresh("console", { mode: "console", token: "token" }, false); const catalog = new ModelCatalog(async () => new Response("no", { status: 503 }), cache); - assert.deepEqual((await catalog.refreshSafely("zen", { mode: "zen", token: "token" }, false)).map((item) => item.id), ["cached"]); + assert.deepEqual((await catalog.refreshSafely("console", { mode: "console", token: "token" }, false)).map((item) => item.id), ["cached"]); }); test("resolves Console models from the organization configuration", async () => { @@ -155,6 +155,28 @@ test("resolves Console models from the organization configuration", async () => assert.equal(requestedHeaders?.get("x-org-id"), "org"); }); +test("excludes other Console-managed providers such as opencode-go from the Console group", async () => { + const catalog = new ModelCatalog(async () => new Response(JSON.stringify({ config: { provider: { + opencode: { models: { "glm-5.3": { id: "glm-5.3", name: "GLM-5.3", limit: { context: 1000, output: 100 } } } }, + "opencode-go": { models: { "kimi-k3": { id: "kimi-k3", name: "Kimi K3", limit: { context: 1000, output: 100 } } } }, + openai: { models: { "gpt-5": { id: "gpt-5", name: "GPT-5", limit: { context: 1000, output: 100 } } } }, + } } }))); + const models = await catalog.refresh("console", { mode: "console", token: "token", server: "https://example.test", orgId: "org" }, false); + assert.deepEqual(models.map((model) => model.id), ["glm-5.3"]); + assert.equal(models[0].providerId, "opencode"); +}); + +test("returns no Console models when the organization config omits the opencode provider", async () => { + const catalog = new ModelCatalog(async () => new Response(JSON.stringify({ config: { provider: { + "opencode-go": { models: { "kimi-k3": { id: "kimi-k3", limit: { context: 1000, output: 100 } } } }, + } } }))); + await assert.rejects( + () => catalog.refresh("console", { mode: "console", token: "token", server: "https://example.test", orgId: "org" }, false), + /no usable models/, + ); + assert.deepEqual(catalog.list("console"), []); +}); + test("never falls back to a public model list for Console", async () => { const catalog = new ModelCatalog(async () => new Response("unavailable", { status: 503 })); assert.deepEqual(catalog.list("console"), []); @@ -173,12 +195,12 @@ test("invalidates a fresh Console catalog when the active organization changes", }); test("invalidates a fresh public catalog when the authenticated account changes", async () => { - const first = { mode: "zen" as const, token: "first" }; - const second = { mode: "zen" as const, token: "second" }; + const first = { mode: "console" as const, token: "first" }; + const second = { mode: "console" as const, token: "second" }; const catalog = new ModelCatalog(async (input) => String(input).endsWith("/models") ? Response.json({ data: [{ id: "live" }] }) : Response.json({ opencode: { models: { live: { id: "live" } } } })); - await catalog.refresh("zen", first, false); - assert.equal(catalog.isFresh("zen", 60_000, catalogScope("zen", first, false)), true); - assert.equal(catalog.isFresh("zen", 60_000, catalogScope("zen", second, false)), false); + await catalog.refresh("console", first, false); + assert.equal(catalog.isFresh("console", 60_000, catalogScope("console", first, false)), true); + assert.equal(catalog.isFresh("console", 60_000, catalogScope("console", second, false)), false); }); diff --git a/src/models/catalog.ts b/src/models/catalog.ts index 8953326..328678a 100644 --- a/src/models/catalog.ts +++ b/src/models/catalog.ts @@ -2,7 +2,6 @@ import { apiBaseForMode, resolveEndpointKind, type EndpointKind, type OpenCodeMo import type { Credential } from "../auth/auth"; import { ALIAS_MODELS, ModelsDevMetadata, type MetadataCache, type ModelSource, type ProviderSource } from "./metadata"; import type { ModelCost } from "./pricing"; - export interface OpenCodeModel { id: string; rawModelId: string; @@ -70,14 +69,15 @@ export class ModelCatalog { } async refresh(mode: OpenCodeMode, credential: Credential | undefined, freeOnly: boolean, signal?: AbortSignal): Promise { - const models = mode === "console" + const orgScoped = mode === "console" && !credentialIsApiKey(credential ?? { mode, token: "" }); + const models = mode === "console" && credential?.server && !credentialIsApiKey(credential) ? await this.loadConsole(credential, signal) : await this.loadPublic(mode, credential, freeOnly, signal); if (!models.length) throw new Error(`OpenCode ${mode} returned no usable models`); this.current.set(mode, models); this.refreshedAt.set(mode, Date.now()); this.scopes.set(mode, catalogScope(mode, credential, freeOnly)); - if (mode !== "console") { + if (!orgScoped) { try { await this.cache.update(cacheKey(mode, credential, freeOnly), { updatedAt: Date.now(), models }); } catch { /* Catalog availability must not depend on persistence. */ } } @@ -88,7 +88,7 @@ export class ModelCatalog { try { return await this.refresh(mode, credential, freeOnly, signal); } catch { - if (mode === "console") { + if (mode === "console" && credential && credentialIsApiKey(credential) === false) { // Never expose a public or stale model list for an organization-scoped // catalog. An empty picker is safer than showing models from another // organization when the selected organization's config is unavailable. @@ -108,7 +108,7 @@ export class ModelCatalog { } } - private async loadPublic(mode: "zen" | "go", credential: Credential | undefined, freeOnly: boolean, signal?: AbortSignal): Promise { + private async loadPublic(mode: OpenCodeMode, credential: Credential | undefined, freeOnly: boolean, signal?: AbortSignal): Promise { const providerId = mode === "go" ? "opencode-go" : "opencode"; const snapshot = await this.metadata.getOrRefresh(); const provider = snapshot.providers[providerId]; @@ -120,10 +120,10 @@ export class ModelCatalog { for (const [alias, canonical] of Object.entries(ALIAS_MODELS[providerId])) { if (alias in combined && canonical in combined) delete combined[alias]; } - return modelsFromProvider(mode, providerId, provider ?? { id: providerId }, combined, freeOnly && mode === "zen"); + return modelsFromProvider(mode, providerId, provider ?? { id: providerId }, combined, freeOnly && mode === "console"); } - private async loadLiveModels(mode: "zen" | "go", token: string | undefined, signal?: AbortSignal): Promise> { + private async loadLiveModels(mode: OpenCodeMode, token: string | undefined, signal?: AbortSignal): Promise> { const headers: Record = { Accept: "application/json" }; if (token) headers.Authorization = `Bearer ${token}`; const response = await this.fetcher(`${apiBaseForMode(mode).replace(/\/+$/, "")}/models`, { @@ -142,8 +142,8 @@ export class ModelCatalog { return models; } - private async loadConsole(credential: Credential | undefined, signal?: AbortSignal): Promise { - if (!credential?.token || !credential.server) throw new Error("Sign in to OpenCode Console before loading organization models"); + private async loadConsole(credential: Credential, signal?: AbortSignal): Promise { + if (!credential.token || !credential.server) throw new Error("Sign in to OpenCode Console before loading organization models"); if (!credential.orgId) throw new Error("Select an OpenCode Console organization before loading models"); const headers: Record = { Accept: "application/json", Authorization: `Bearer ${credential.token}` }; headers["x-org-id"] = credential.orgId; @@ -152,16 +152,28 @@ export class ModelCatalog { if (!response.ok) throw new Error(`OpenCode Console model configuration failed (${response.status})`); const payload = await response.json() as { config?: { provider?: Record } }; const providers = payload.config?.provider ?? {}; - const entries = Object.entries(providers).flatMap(([id, provider]) => { - const models = provider.models ?? {}; - return modelsFromProvider("console", id, provider, models, false); - }); + // The org config lists every provider the Console manages, including + // `opencode-go` and BYOK entries. The Console model group serves the + // pay-as-you-go gateway only; Go has its own provider group and public + // discovery, so other provider ids are excluded here. + const primary = providers.opencode; + if (!primary) return []; + const entries = modelsFromProvider("console", "opencode", primary, primary.models ?? {}, false); const counts = new Map(); for (const model of entries) counts.set(model.rawModelId, (counts.get(model.rawModelId) ?? 0) + 1); return entries.map((model) => counts.get(model.rawModelId)! > 1 ? { ...model, id: `${model.providerId}/${model.rawModelId}` } : model); } } +/** + * Device-flow Console credentials carry the Console server and organization + * metadata; service-account API keys are bare tokens. Only session + * credentials can load the organization-scoped config catalog. + */ +function credentialIsApiKey(credential: Credential | undefined): boolean { + return !credential?.server; +} + export function modelsFromProvider( mode: OpenCodeMode, providerId: string, @@ -174,7 +186,7 @@ export function modelsFromProvider( if (isInternalTestModel(rawId)) return []; if (freeOnly && (source.cost?.input ?? 1) > 0) return []; const packageName = source.provider?.npm ?? provider.npm; - const baseUrl = source.provider?.api ?? provider.api ?? apiBaseForMode(mode === "console" ? "zen" : mode); + const baseUrl = source.provider?.api ?? provider.api ?? apiBaseForMode(mode); const contextLength = positive(source.limit?.context, 32768); const maxOutputTokens = positive(source.limit?.output, Math.min(contextLength, 8192)); const modelId = source.id ?? rawId; @@ -215,11 +227,11 @@ function completeCost(cost: ModelSource["cost"]): ModelCost | undefined { } export function catalogScope(mode: OpenCodeMode, credential: Credential | undefined, freeOnly: boolean): string { - if (mode === "console") return `${mode}:${credential?.server ?? ""}:${credential?.orgId ?? ""}`; + if (mode === "console" && credential?.server) return `${mode}:${credential.server}:${credential.orgId ?? ""}`; return `${mode}:${freeOnly ? "free" : "all"}:${credential?.token ? tokenFingerprint(credential.token) : "anonymous"}`; } -function cacheKey(mode: "zen" | "go", credential: Credential | undefined, freeOnly: boolean): string { +function cacheKey(mode: OpenCodeMode, credential: Credential | undefined, freeOnly: boolean): string { return `${CACHE_KEY_PREFIX}:${catalogScope(mode, credential, freeOnly)}`; } diff --git a/src/models/metadata.ts b/src/models/metadata.ts index 282f9ef..6b9e46e 100644 --- a/src/models/metadata.ts +++ b/src/models/metadata.ts @@ -49,9 +49,32 @@ export interface ModelsDevSnapshot { // Mirrors only carry data that a canonical upstream entry already publishes // for the same model; nothing is guessed. `deepseek-flash` mirrors the // DeepSeek provider entry, and the anonymous-discovery Go ids mirror their -// Zen provider entries. +// Console provider entries. const SUPPLEMENTAL_MODELS: Readonly>>> = { - opencode: {}, + opencode: { + // Mirrored from the `opencode-go` provider entry; served live on the + // Console (former Zen) gateway before models.dev catalogs it there. + "jev-1.13": { + id: "jev-1.13", + name: "Jev 1.13", + family: "jev", + limit: { context: 262_144, input: 196_608, output: 131_072 }, + reasoning: true, + tool_call: true, + modalities: { input: ["text"] }, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, + "jev-1.13-free": { + id: "jev-1.13-free", + name: "Jev 1.13 Free", + family: "jev", + limit: { context: 262_144, input: 196_608, output: 131_072 }, + reasoning: true, + tool_call: true, + modalities: { input: ["text"] }, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, + }, "opencode-go": { "hy3-preview": { id: "hy3-preview", @@ -133,6 +156,38 @@ const SUPPLEMENTAL_MODELS: Readonly { }; }; const providers = manifest.contributes.languageModelChatProviders; - for (const vendor of ["opencodezen", "opencodego", "opencodeconsole"]) { + for (const vendor of ["opencodeconsole", "opencodego"]) { const provider = providers.find((item) => item.vendor === vendor); assert.ok(provider); assert.equal(provider.managementCommand, undefined); @@ -22,6 +22,7 @@ test("declares native API-key and Console-profile provider entries", () => { const required = configuration.required; assert.deepEqual(required, vendor === "opencodeconsole" ? undefined : ["apiKey"]); if (vendor !== "opencodeconsole") assert.equal(configuration.properties?.apiKey.secret, true); + if (vendor === "opencodeconsole") assert.equal(configuration.properties?.apiKey.secret, true); } for (const command of ["opencodeCopilot.refreshModels", "opencodeCopilot.testConnection"]) { assert.match( diff --git a/src/provider.ts b/src/provider.ts index 67edd57..186a21e 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -81,7 +81,7 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider item.rawModelId === information.rawModelId); @@ -283,7 +282,7 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider { if (this.mode === "console") { + // Service-account API key entries take precedence over device-code + // profiles, mirroring the upstream key method's precedence. + const apiKey = typeof configuration.apiKey === "string" ? configuration.apiKey.trim() : ""; + if (apiKey) { + const legacy = await this.auth.getCredential("console"); + return { + credential: { mode: "console", token: apiKey }, + credentialId: legacy?.token === apiKey + ? "legacy" + : `key-${createHash("sha256").update(apiKey).digest("hex").slice(0, 16)}`, + }; + } const profile = consoleProfileFromConfiguration(configuration); const credential = await this.auth.getCredential("console", false, profile); return credential ? { credential, credentialId: `profile-${profile}`, profile } : undefined; @@ -322,7 +333,6 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider { - assert.deepEqual(Object.keys(OPENCODE_PROVIDER_DEFINITIONS), ["zen", "go", "console"]); +test("defines distinct Console and Go model-provider groups", () => { + assert.deepEqual(Object.keys(OPENCODE_PROVIDER_DEFINITIONS), ["console", "go"]); assert.deepEqual( Object.values(OPENCODE_PROVIDER_DEFINITIONS).map(({ vendor }) => vendor), - ["opencodezen", "opencodego", "opencodeconsole"], + ["opencodeconsole", "opencodego"], ); - assert.equal(providerDefinition("zen").displayName, "OpenCode Zen"); - assert.equal(providerDefinition("go").displayName, "OpenCode Go"); assert.equal(providerDefinition("console").displayName, "OpenCode Console"); + assert.equal(providerDefinition("go").displayName, "OpenCode Go"); }); diff --git a/src/provider/definitions.ts b/src/provider/definitions.ts index 53bf5e3..9299a22 100644 --- a/src/provider/definitions.ts +++ b/src/provider/definitions.ts @@ -7,21 +7,16 @@ export interface OpenCodeProviderDefinition { } export const OPENCODE_PROVIDER_DEFINITIONS: Readonly> = { - zen: { - mode: "zen", - vendor: "opencodezen", - displayName: "OpenCode Zen", + console: { + mode: "console", + vendor: "opencodeconsole", + displayName: "OpenCode Console", }, go: { mode: "go", vendor: "opencodego", displayName: "OpenCode Go", }, - console: { - mode: "console", - vendor: "opencodeconsole", - displayName: "OpenCode Console", - }, }; export function providerDefinition(mode: OpenCodeMode): OpenCodeProviderDefinition { diff --git a/src/transport/protocol.test.ts b/src/transport/protocol.test.ts index d68c3ee..6a0ff5a 100644 --- a/src/transport/protocol.test.ts +++ b/src/transport/protocol.test.ts @@ -1,6 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildAuthHeaders, buildRequestHeaders, DEFAULT_CONSOLE_SERVER, endpointUrl, resolveConsoleVerificationUrl, resolveEndpointKind } from "./protocol"; +import { apiBaseForMode, buildAuthHeaders, buildRequestHeaders, DEFAULT_CONSOLE_SERVER, endpointUrl, normalizeMode, resolveConsoleVerificationUrl, resolveEndpointKind } from "./protocol"; + +test("maps legacy zen mode values onto the Console gateway", () => { + assert.equal(normalizeMode("zen"), "console"); + assert.equal(normalizeMode("console"), "console"); + assert.equal(normalizeMode("go"), "go"); + assert.equal(normalizeMode(undefined), "console"); + assert.equal(apiBaseForMode("console"), "https://opencode.ai/zen/v1"); + assert.equal(apiBaseForMode("go"), "https://opencode.ai/zen/go/v1"); +}); test("resolves Console device verification URLs onto the /console subpath", () => { const complete = "/console/device?user_code=ABCD-EFGH&client_id=opencode-cli"; @@ -37,12 +46,12 @@ test("catalog headers cannot override credentials or request identity", () => { test("routes known OpenCode model families to their native gateway shape", () => { assert.equal(resolveEndpointKind("gpt-5.6-luna", "go"), "responses"); - assert.equal(resolveEndpointKind("gpt-6-astra", "zen"), "responses"); - assert.equal(resolveEndpointKind("claude-fable-5", "zen"), "messages"); - assert.equal(resolveEndpointKind("gemini-3.5-flash", "zen"), "google"); + assert.equal(resolveEndpointKind("gpt-6-astra", "console"), "responses"); + assert.equal(resolveEndpointKind("claude-fable-5", "console"), "messages"); + assert.equal(resolveEndpointKind("gemini-3.5-flash", "console"), "google"); assert.equal(resolveEndpointKind("grok-4.6", "go"), "responses"); - assert.equal(resolveEndpointKind("grok-build-0.1", "zen"), "responses"); - assert.equal(resolveEndpointKind("muse-spark-1.3", "zen"), "responses"); + assert.equal(resolveEndpointKind("grok-build-0.1", "console"), "responses"); + assert.equal(resolveEndpointKind("muse-spark-1.3", "console"), "responses"); assert.equal(resolveEndpointKind("muse-spark-1.2-contributor", "go"), "responses"); assert.equal(resolveEndpointKind("minimax-m2.7", "go"), "messages"); assert.equal(resolveEndpointKind("minimax-m3", "go"), "messages"); @@ -50,7 +59,7 @@ test("routes known OpenCode model families to their native gateway shape", () => assert.equal(resolveEndpointKind("qwen3.7-max", "go"), "messages"); assert.equal(resolveEndpointKind("qwen3.8-max", "go"), "messages"); assert.equal(resolveEndpointKind("qwen3.8-flash", "go"), "messages"); - assert.equal(resolveEndpointKind("qwen3.6-plus", "zen"), "messages"); + assert.equal(resolveEndpointKind("qwen3.6-plus", "console"), "messages"); assert.equal(resolveEndpointKind("qwen3.5-plus", "go"), "messages"); // Orvix-style dashed qwen ids route like their OpenCode spellings. assert.equal(resolveEndpointKind("qwen-3.8-max", "go"), "messages"); @@ -59,11 +68,11 @@ test("routes known OpenCode model families to their native gateway shape", () => // models.dev package name is missing. assert.equal(resolveEndpointKind("grok-5", "go"), "responses"); assert.equal(resolveEndpointKind("qwen3.9-coder", "go"), "messages"); - assert.equal(resolveEndpointKind("qwen3.6-plus-free", "zen"), "messages"); + assert.equal(resolveEndpointKind("qwen3.6-plus-free", "console"), "messages"); }); test("keeps undiscovered families on the shared chat completions shape", () => { - assert.equal(resolveEndpointKind("minimax-m3", "zen"), "chat-completions"); + assert.equal(resolveEndpointKind("minimax-m3", "console"), "chat-completions"); assert.equal(resolveEndpointKind("glm-5.3", "go"), "chat-completions"); assert.equal(resolveEndpointKind("kimi-k3", "go"), "chat-completions"); assert.equal(resolveEndpointKind("deepseek-v4-pro", "go"), "chat-completions"); @@ -73,8 +82,8 @@ test("keeps undiscovered families on the shared chat completions shape", () => { assert.equal(resolveEndpointKind("longcat-2.0", "go"), "chat-completions"); assert.equal(resolveEndpointKind("mimo-v2.5", "go"), "chat-completions"); assert.equal(resolveEndpointKind("omen-alpha", "go"), "chat-completions"); - assert.equal(resolveEndpointKind("big-pickle", "zen"), "chat-completions"); - assert.equal(resolveEndpointKind("nemotron-3-ultra-free", "zen"), "chat-completions"); + assert.equal(resolveEndpointKind("big-pickle", "console"), "chat-completions"); + assert.equal(resolveEndpointKind("nemotron-3-ultra-free", "console"), "chat-completions"); assert.equal(resolveEndpointKind("gemini-3.5-flash", "go"), "chat-completions"); }); diff --git a/src/transport/protocol.ts b/src/transport/protocol.ts index 0e7d6cc..9afd0b2 100644 --- a/src/transport/protocol.ts +++ b/src/transport/protocol.ts @@ -1,12 +1,22 @@ export const DEFAULT_CONSOLE_SERVER = "https://opencode.ai/console"; -export const ZEN_API_BASE_URL = "https://opencode.ai/zen/v1"; +export const CONSOLE_API_BASE_URL = "https://opencode.ai/zen/v1"; export const GO_API_BASE_URL = "https://opencode.ai/zen/go/v1"; export const OPENCODE_CLIENT_ID = "opencode-cli"; export const OPENCODE_CLIENT = "opencode-copilot-chat"; -export type OpenCodeMode = "zen" | "go" | "console"; +/** Legacy zen value of `opencode.defaultMode` and the inline gateway setting. */ +export const LEGACY_ZEN_MODE = "zen"; + +export type OpenCodeMode = "console" | "go"; export type EndpointKind = "chat-completions" | "messages" | "responses" | "google"; +/** Modes that can appear in persisted settings or state written by older versions. */ +export type LegacyOpenCodeMode = OpenCodeMode | "zen"; + +export function normalizeMode(value: unknown): OpenCodeMode { + return value === "go" ? "go" : "console"; +} + export function resolveConsoleVerificationUrl(server: string, verification: string): string { let url: URL; try { @@ -21,7 +31,7 @@ export function resolveConsoleVerificationUrl(server: string, verification: stri } export function apiBaseForMode(mode: OpenCodeMode): string { - return mode === "go" ? GO_API_BASE_URL : ZEN_API_BASE_URL; + return mode === "go" ? GO_API_BASE_URL : CONSOLE_API_BASE_URL; } export function buildAuthHeaders(endpoint: EndpointKind, token: string): Record { @@ -70,7 +80,7 @@ export function resolveEndpointKind( if (/^muse-spark-/i.test(modelId)) return "responses"; if (/^qwen/i.test(modelId)) return "messages"; if (mode === "go" && /^minimax-/i.test(modelId)) return "messages"; - if (mode === "zen" && /^gemini-/i.test(modelId)) return "google"; + if (mode === "console" && /^gemini-/i.test(modelId)) return "google"; return "chat-completions"; }