Merge pull request #1425 from tomdurrant/fix/opencode-go-api-key-env
Fix opencode-go model sync — pass API key to CLI and strip prefix from model IDs
This commit is contained in:
15
.changeset/fix-opencode-go-api-key-env.md
Normal file
15
.changeset/fix-opencode-go-api-key-env.md
Normal file
@@ -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.
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,14 +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" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => console.log(`[${scope}] ${message}`),
|
||||
});
|
||||
(scope, message) => console.log(`[${scope}] ${message}`),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
|
||||
@@ -84,7 +84,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";
|
||||
|
||||
@@ -1761,14 +1761,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" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => logSink.log(message, scope),
|
||||
});
|
||||
(scope, message) => logSink.log(message, scope),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
@@ -2082,14 +2080,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" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => logSink.log(message, scope),
|
||||
});
|
||||
(scope, message) => logSink.log(message, scope),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
|
||||
@@ -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,14 +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" };
|
||||
}
|
||||
return await refreshOpencodeGoModels({
|
||||
return await handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage,
|
||||
store,
|
||||
modelRegistry,
|
||||
log: (scope, message) => console.log(`[${scope}] ${message}`),
|
||||
});
|
||||
(scope, message) => console.log(`[${scope}] ${message}`),
|
||||
);
|
||||
},
|
||||
getClaudeCliExtensionStatus: () => {
|
||||
const r = getCachedClaudeCliResolution();
|
||||
|
||||
@@ -205,15 +205,22 @@ 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;
|
||||
|
||||
if (!bareModel) {
|
||||
throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
name: normalizedId,
|
||||
id: bareModel,
|
||||
name: bareModel,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGoModels(): Promise<string[]> {
|
||||
export async function discoverOpencodeGoModels(apiKey?: string): Promise<string[]> {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const env: Record<string, string> = { ...process.env as Record<string, string> };
|
||||
if (apiKey) {
|
||||
env.OPENCODE_API_KEY = apiKey;
|
||||
}
|
||||
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
@@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise<string[]> {
|
||||
export async function refreshOpencodeGoModels(options: {
|
||||
modelRegistry: ModelRegistryLike;
|
||||
log: (scope: string, message: string) => void;
|
||||
apiKey?: string;
|
||||
}): Promise<OpencodeGoRefreshResult> {
|
||||
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" };
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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",
|
||||
@@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
|
||||
}
|
||||
|
||||
if (settings.opencodeGoModelSync !== false) {
|
||||
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
|
||||
const opencodeGoApiKey = await options.authStorage.getApiKey("opencode-go") ?? await options.authStorage.getApiKey("opencode");
|
||||
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log, apiKey: opencodeGoApiKey });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared handler for the onApiKeySaved callback used by serve, daemon, and
|
||||
* dashboard. Resolves the opencode-go API key from auth storage (falling back
|
||||
* to the "opencode" provider ID) and triggers a model refresh, respecting the
|
||||
* opencodeGoModelSync setting.
|
||||
*/
|
||||
export async function handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage: AuthStorageLike,
|
||||
store: { getSettings: () => Promise<SettingsLike> },
|
||||
modelRegistry: ModelRegistryLike,
|
||||
log: (scope: string, message: string) => void,
|
||||
): Promise<OpencodeGoRefreshResult | 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({ modelRegistry, log, apiKey: opencodeGoKey });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user