feat(FN-5424): add opencode-go provider to settings with model refresh on k

Exposes the `opencode-go` provider via `startup-model-sync`, wires it into the daemon and serve commands, and adds a refresh-status indicator in the SettingsModal that triggers model reloading whenever the provider key is saved. Includes a changeset, settings documentation, and corresponding tests a

Fusion-Task-Id: FN-5424
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 22:33:40 -07:00
committed by gsxdsm
parent e96cb09982
commit b12ff26f36
15 changed files with 374 additions and 35 deletions

View File

@@ -112,6 +112,27 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("tavily");
});
it("always includes opencode-go when registry has no opencode models", () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).toContain("opencode-go");
});
it("filters opencode-go from API key providers when OAuth provider id collides", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [{ id: "opencode-go", name: "Opencode Go OAuth" }]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).not.toContain("opencode-go");
});
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = tempWorkspace("fusion-provider-auth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");

View File

@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, syncStartupModels } from "../startup-model-sync.js";
import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
@@ -242,6 +242,52 @@ describe("startup-model-sync", () => {
);
});
it("returns refresh result for opencode-go happy path", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\nopencode/gpt-5\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
const result = await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() });
expect(result).toEqual({ registeredCount: 1 });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [expect.objectContaining({ id: "opencode-go/gpt-5" })],
}));
});
it("returns no-models reason when cli output has no models", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\n"));
proc.emit("exit", 0);
});
return proc;
});
const result = await refreshOpencodeGoModels({ modelRegistry: { registerProvider: vi.fn() }, log: vi.fn() });
expect(result).toEqual({ registeredCount: 0, reason: "no-models-from-cli" });
});
it("returns cli-failed reason when spawn errors", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => proc.emit("error", new Error("spawn opencode ENOENT")));
return proc;
});
const result = await refreshOpencodeGoModels({ modelRegistry: { registerProvider: vi.fn() }, log: vi.fn() });
expect(result.registeredCount).toBe(0);
expect(result.reason).toBe("cli-failed");
expect(result.error).toContain("ENOENT");
});
it("logs failures and continues", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();

View File

@@ -69,7 +69,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 { syncStartupModels } from "./startup-model-sync.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -712,6 +712,19 @@ export async function runDaemon(opts: DaemonOptions = {}) {
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
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({
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;

View File

@@ -75,7 +75,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 { syncStartupModels } from "./startup-model-sync.js";
import { refreshOpencodeGoModels, 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";
@@ -1626,6 +1626,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
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({
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;
@@ -1930,6 +1943,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
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({
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;

View File

@@ -46,6 +46,7 @@ const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "opencode-go", name: "Opencode (Go)" },
{ id: "tavily", name: "Tavily" },
{ id: "zai", name: "Zai" },
];

View File

@@ -69,7 +69,7 @@ import {
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -819,6 +819,19 @@ export async function runServe(
// is configured. The runner logs its own outcome and swallows errors.
maybeInstallClaudeSkillForNewProject(path);
},
onApiKeySaved: async (providerId: string) => {
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({
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
if (!r) return null;

View File

@@ -65,6 +65,12 @@ interface StartupSyncOptions {
log: (scope: string, message: string) => void;
}
export type OpencodeGoRefreshResult = {
registeredCount: number;
reason?: "no-models-from-cli" | "cli-failed" | "disabled-by-settings";
error?: string;
};
function parseCost(value?: string): number {
const n = parseFloat(value || "0");
return Number.isNaN(n) ? 0 : n * 1_000_000;
@@ -197,7 +203,7 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti
log("openrouter", `Synced ${models.length} models from OpenRouter API`);
}
function normalizeOpencodeGoModel(modelId: string): ModelConfig {
export function normalizeOpencodeGoModel(modelId: string): ModelConfig {
const trimmed = modelId.trim();
const normalizedId = trimmed.startsWith("opencode/")
? `opencode-go/${trimmed.slice("opencode/".length)}`
@@ -227,7 +233,7 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
return [...ids];
}
async function discoverOpencodeGoModels(): Promise<string[]> {
export async function discoverOpencodeGoModels(): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
@@ -263,22 +269,32 @@ async function discoverOpencodeGoModels(): Promise<string[]> {
});
}
async function syncOpencodeGoModels(options: StartupSyncOptions): Promise<void> {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return;
}
export async function refreshOpencodeGoModels(options: {
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
}): Promise<OpencodeGoRefreshResult> {
try {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
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);
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
api: "openai-completions",
models,
});
log("opencode-go", `Synced ${models.length} models from opencode CLI`);
const models = modelIds.map(normalizeOpencodeGoModel);
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
api: "openai-completions",
models,
});
log("opencode-go", `Synced ${models.length} models from opencode CLI`);
return { registeredCount: models.length };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("opencode-go", `Failed to sync models: ${message}`);
return { registeredCount: 0, reason: "cli-failed", error: message };
}
}
export async function syncStartupModels(options: StartupSyncOptions): Promise<void> {
@@ -294,11 +310,6 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
}
if (settings.opencodeGoModelSync !== false) {
try {
await syncOpencodeGoModels(options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("opencode-go", `Failed to sync models: ${message}`);
}
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
}
}