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:
committed by
gsxdsm
parent
e96cb09982
commit
b12ff26f36
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user