From 295d726768e552dbb5a36e1a317be45913c9d0d5 Mon Sep 17 00:00:00 2001 From: Tom Durrant Date: Fri, 5 Jun 2026 10:07:27 +1000 Subject: [PATCH 1/3] fix: pass OPENCODE_API_KEY env var when syncing opencode-go models discoverOpencodeGoModels() spawns 'opencode models opencode --refresh' but never passed the saved API key as OPENCODE_API_KEY. The opencode CLI's internal OpencodePlugin checks this env var to decide whether to show paid models; without it, only free (cost.input === 0) models appear. Now threads the apiKey from auth storage through to the spawned process environment, so the CLI sees the user's Go subscription and returns the full model catalog including paid models like Claude, GPT-5.x, Gemini, etc. Callers in serve.ts, daemon.ts, and dashboard.ts all updated to read the key from dashboardAuthStorage and pass it to refreshOpencodeGoModels. syncStartupModels reads from authStorage in StartupSyncOptions. --- packages/cli/src/commands/daemon.ts | 2 ++ packages/cli/src/commands/dashboard.ts | 4 ++++ packages/cli/src/commands/serve.ts | 2 ++ packages/cli/src/commands/startup-model-sync.ts | 15 +++++++++++---- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 27d1af4f67..ead75bf96b 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -724,9 +724,11 @@ export async function runDaemon(opts: DaemonOptions = {}) { if (settings.opencodeGoModelSync === false) { return { registeredCount: 0, reason: "disabled-by-settings" }; } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); return await refreshOpencodeGoModels({ modelRegistry, log: (scope, message) => console.log(`[${scope}] ${message}`), + apiKey: opencodeGoKey, }); }, getClaudeCliExtensionStatus: () => { diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 8df4be7de2..a41f7b8055 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1764,9 +1764,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (settings.opencodeGoModelSync === false) { return { registeredCount: 0, reason: "disabled-by-settings" }; } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); return await refreshOpencodeGoModels({ modelRegistry, log: (scope, message) => logSink.log(message, scope), + apiKey: opencodeGoKey, }); }, getClaudeCliExtensionStatus: () => { @@ -2085,9 +2087,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (settings.opencodeGoModelSync === false) { return { registeredCount: 0, reason: "disabled-by-settings" }; } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); return await refreshOpencodeGoModels({ modelRegistry, log: (scope, message) => logSink.log(message, scope), + apiKey: opencodeGoKey, }); }, getClaudeCliExtensionStatus: () => { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8f01b2c798..2d6a315d6c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -831,9 +831,11 @@ export async function runServe( if (settings.opencodeGoModelSync === false) { return { registeredCount: 0, reason: "disabled-by-settings" }; } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); return await refreshOpencodeGoModels({ modelRegistry, log: (scope, message) => console.log(`[${scope}] ${message}`), + apiKey: opencodeGoKey, }); }, getClaudeCliExtensionStatus: () => { diff --git a/packages/cli/src/commands/startup-model-sync.ts b/packages/cli/src/commands/startup-model-sync.ts index 79d3b88220..7322d6cf5b 100644 --- a/packages/cli/src/commands/startup-model-sync.ts +++ b/packages/cli/src/commands/startup-model-sync.ts @@ -233,10 +233,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] { return [...ids]; } -export async function discoverOpencodeGoModels(): Promise { +export async function discoverOpencodeGoModels(apiKey?: string): Promise { return await new Promise((resolve, reject) => { + const env: Record = { ...process.env as Record }; + if (apiKey) { + env.OPENCODE_API_KEY = apiKey; + } const proc = spawn("opencode", ["models", "opencode", "--refresh"], { stdio: ["ignore", "pipe", "pipe"], + env, }); let stdout = ""; @@ -272,10 +277,11 @@ export async function discoverOpencodeGoModels(): Promise { export async function refreshOpencodeGoModels(options: { modelRegistry: ModelRegistryLike; log: (scope: string, message: string) => void; + apiKey?: string; }): Promise { try { - const { modelRegistry, log } = options; - const modelIds = await discoverOpencodeGoModels(); + const { modelRegistry, log, apiKey } = options; + const modelIds = await discoverOpencodeGoModels(apiKey); if (modelIds.length === 0) { log("opencode-go", "No models discovered from opencode CLI refresh"); return { registeredCount: 0, reason: "no-models-from-cli" }; @@ -310,6 +316,7 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise Date: Fri, 5 Jun 2026 10:40:01 +1000 Subject: [PATCH 2/3] fix: strip provider prefix from opencode-go model IDs normalizeOpencodeGoModel was prefixing model IDs with 'opencode-go/' (e.g. 'opencode-go/deepseek-v4-flash'), but the Pi SDK sends the model id field as the model name in API requests. The OpenCode API expects bare model names (e.g. 'deepseek-v4-flash'), not prefixed ones. Now strips the 'opencode/' or 'opencode-go/' prefix entirely so the registered model ID matches what the API expects. --- packages/cli/src/commands/startup-model-sync.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/startup-model-sync.ts b/packages/cli/src/commands/startup-model-sync.ts index 7322d6cf5b..ef5a1d30c2 100644 --- a/packages/cli/src/commands/startup-model-sync.ts +++ b/packages/cli/src/commands/startup-model-sync.ts @@ -205,15 +205,18 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti export function normalizeOpencodeGoModel(modelId: string): ModelConfig { const trimmed = modelId.trim(); - const normalizedId = trimmed.startsWith("opencode/") - ? `opencode-go/${trimmed.slice("opencode/".length)}` - : trimmed.startsWith("opencode-go/") - ? trimmed - : `opencode-go/${trimmed}`; + // Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK + // already routes requests by provider, and the OpenCode API expects the + // bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash"). + const bareModel = trimmed.startsWith("opencode-go/") + ? trimmed.slice("opencode-go/".length) + : trimmed.startsWith("opencode/") + ? trimmed.slice("opencode/".length) + : trimmed; return { - id: normalizedId, - name: normalizedId, + id: bareModel, + name: bareModel, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, From 978d07c96caa873db07ad27f2699cb005a337ac6 Mon Sep 17 00:00:00 2001 From: Tom Durrant Date: Fri, 5 Jun 2026 11:12:40 +1000 Subject: [PATCH 3/3] Address PR review comments - Update test assertions for bare model IDs - Add deduplication guard for models with same bare ID - Add validation for empty model IDs after prefix stripping - Add test for API key forwarded as env var to spawn - Add test for deduplication and empty model ID guard - Extract shared handleOpencodeGoApiKeySaved helper - Add changeset for the published package --- .changeset/fix-opencode-go-api-key-env.md | 15 +++++ .../__tests__/startup-model-sync.test.ts | 56 +++++++++++++++++-- packages/cli/src/commands/daemon.ts | 16 ++---- packages/cli/src/commands/dashboard.ts | 30 ++++------ packages/cli/src/commands/serve.ts | 16 ++---- .../cli/src/commands/startup-model-sync.ts | 34 ++++++++++- 6 files changed, 123 insertions(+), 44 deletions(-) create mode 100644 .changeset/fix-opencode-go-api-key-env.md diff --git a/.changeset/fix-opencode-go-api-key-env.md b/.changeset/fix-opencode-go-api-key-env.md new file mode 100644 index 0000000000..d43e43e884 --- /dev/null +++ b/.changeset/fix-opencode-go-api-key-env.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": patch +--- + +Fix opencode-go model sync: pass API key to CLI and strip provider prefix from model IDs + +Two bugs when using OpenCode Go as a provider: + +1. **Model discovery only returned free models** — the saved Go API key was never passed as `OPENCODE_API_KEY` to the spawned `opencode models opencode --refresh` process. The CLI's internal plugin checks this env var and, when absent, disables all paid models (those with `cost.input > 0`). Only 20 free models appeared instead of all 67. + +2. **API requests failed with 401** — `normalizeOpencodeGoModel` was registering models with prefixed IDs like `opencode-go/deepseek-v4-flash`. The Pi SDK sends `model.id` verbatim in API requests; the OpenCode API expects bare model names (e.g. `deepseek-v4-flash`). The prefix is now stripped during normalization. + +Also deduplicates models when the CLI emits both `opencode/foo` and `opencode-go/foo` for the same model, guards against empty model IDs, and refactors the duplicated `onApiKeySaved` handler into a shared `handleOpencodeGoApiKeySaved` helper. + +After this change, users must re-select their opencode-go model in Settings because model IDs have changed from prefixed to bare names. diff --git a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts index 4daa09537b..9e2d09ac07 100644 --- a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts +++ b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts @@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({ spawn: mockSpawn, })); -import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js"; +import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js"; type MockProcess = EventEmitter & { stdout: EventEmitter; @@ -75,8 +75,8 @@ describe("startup-model-sync", () => { expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) })); expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ models: expect.arrayContaining([ - expect.objectContaining({ id: "opencode-go/gpt-5" }), - expect.objectContaining({ id: "opencode-go/custom" }), + expect.objectContaining({ id: "gpt-5" }), + expect.objectContaining({ id: "custom" }), ]), })); expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced")); @@ -257,7 +257,7 @@ describe("startup-model-sync", () => { expect(result).toEqual({ registeredCount: 1 }); expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ - models: [expect.objectContaining({ id: "opencode-go/gpt-5" })], + models: [expect.objectContaining({ id: "gpt-5" })], })); }); @@ -319,4 +319,52 @@ describe("startup-model-sync", () => { "opencode-go/custom", ]); }); + + it("deduplicates models when CLI emits both prefix forms", async () => { + mockSpawn.mockImplementation(() => { + const proc = createSpawnProcess(); + queueMicrotask(() => { + proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n")); + proc.emit("exit", 0); + }); + return proc; + }); + + const registerProvider = vi.fn(); + await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() }); + + expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ + models: [ + expect.objectContaining({ id: "foo" }), + expect.objectContaining({ id: "bar" }), + ], + })); + }); + + it("throws on empty model ID after prefix stripping", () => { + expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name"); + expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name"); + }); + + it("accepts apiKey and passes it as env var to spawn", async () => { + mockSpawn.mockImplementation(() => { + const proc = createSpawnProcess(); + queueMicrotask(() => { + proc.stdout.emit("data", Buffer.from("opencode/foo\n")); + proc.emit("exit", 0); + }); + return proc; + }); + + const registerProvider = vi.fn(); + await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" }); + + expect(mockSpawn).toHaveBeenCalledWith( + "opencode", + ["models", "opencode", "--refresh"], + expect.objectContaining({ + env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }), + }), + ); + }); }); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index ead75bf96b..31172e6bb7 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -71,7 +71,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes @@ -720,16 +720,12 @@ export async function runDaemon(opts: DaemonOptions = {}) { if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => console.log(`[${scope}] ${message}`), - apiKey: opencodeGoKey, - }); + (scope, message) => console.log(`[${scope}] ${message}`), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index a41f7b8055..21cbfbba65 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -83,7 +83,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js" import { resolveSelfExtension } from "./self-extension.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js"; import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js"; @@ -1760,16 +1760,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => logSink.log(message, scope), - apiKey: opencodeGoKey, - }); + (scope, message) => logSink.log(message, scope), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); @@ -2083,16 +2079,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => logSink.log(message, scope), - apiKey: opencodeGoKey, - }); + (scope, message) => logSink.log(message, scope), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2d6a315d6c..a19b35813a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -71,7 +71,7 @@ import { } from "./llama-cpp-extension.js"; import { resolveSelfExtension } from "./self-extension.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; @@ -827,16 +827,12 @@ export async function runServe( if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => console.log(`[${scope}] ${message}`), - apiKey: opencodeGoKey, - }); + (scope, message) => console.log(`[${scope}] ${message}`), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/startup-model-sync.ts b/packages/cli/src/commands/startup-model-sync.ts index ef5a1d30c2..2700c448d6 100644 --- a/packages/cli/src/commands/startup-model-sync.ts +++ b/packages/cli/src/commands/startup-model-sync.ts @@ -214,6 +214,10 @@ export function normalizeOpencodeGoModel(modelId: string): ModelConfig { ? trimmed.slice("opencode/".length) : trimmed; + if (!bareModel) { + throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`); + } + return { id: bareModel, name: bareModel, @@ -290,7 +294,15 @@ export async function refreshOpencodeGoModels(options: { return { registeredCount: 0, reason: "no-models-from-cli" }; } - const models = modelIds.map(normalizeOpencodeGoModel); + const normalized = modelIds.map(normalizeOpencodeGoModel); + // Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo" + // which normalize to the same bare ID. + const seen = new Set(); + const models = normalized.filter((m) => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); modelRegistry.registerProvider("opencode-go", { baseUrl: "https://api.opencode.ai/v1", apiKey: "OPENCODE_API_KEY", @@ -323,3 +335,23 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise Promise }, + modelRegistry: ModelRegistryLike, + log: (scope: string, message: string) => void, +): Promise { + const settings = await store.getSettings(); + if (settings.opencodeGoModelSync === false) { + return { registeredCount: 0, reason: "disabled-by-settings" }; + } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); + return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey }); +}