feat(FN-3227): add startup model sync for opencode-go commands

- Add shared startup model sync helper and integrate it into dashboard, serve, and daemon command startup flows
- Introduce opencode-go model sync setting in core schema/types and expose it in dashboard settings UI
- Add CLI regression tests covering startup model sync behavior and command wiring
- Document the new startup model sync setting and related CLI behavior updates

Fusion-Task-Id: FN-3227
This commit is contained in:
Fusion
2026-05-04 16:25:47 -07:00
committed by gsxdsm
parent 9415d33f72
commit 1dce4ee002
14 changed files with 475 additions and 126 deletions

View File

@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
const mocks = vi.hoisted(() => {
type ListenCall = {
port: number;
@@ -584,6 +592,11 @@ vi.mock("../task-lifecycle.js", () => ({
const { runDaemon } = await import("../daemon.js");
describe("runDaemon", () => {
it("invokes shared startup model sync", async () => {
const { runDaemon } = await import("../daemon.js");
await runDaemon({});
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
const originalCwd = process.cwd;
const originalExit = process.exit;

View File

@@ -2,6 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
const CLI_PACKAGE_VERSION = (
JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8")) as { version: string }
).version;
@@ -735,6 +743,13 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
// ── Tests ───────────────────────────────────────────────────────────
describe("runDashboard — startup model sync", () => {
it("invokes shared startup model sync", async () => {
await runDashboard(0, { open: false });
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
});
function resetGitHubMocks() {
mockFindPrForBranch.mockReset();
mockCreatePr.mockReset();

View File

@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
// ── Multi-project test fixtures ─────────────────────────────────────────
//
// Test fixtures model at least two registered projects with distinct IDs/paths
@@ -649,6 +657,11 @@ vi.mock("../task-lifecycle.js", () => ({
const { runServe } = await import("../serve.js");
describe("runServe", () => {
it("invokes shared startup model sync", async () => {
const { runServe } = await import("../serve.js");
await runServe(4040, {});
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;

View File

@@ -0,0 +1,123 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
function createSpawnProcess(): MockProcess {
const proc = new EventEmitter() as MockProcess;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
return proc;
}
describe("startup-model-sync", () => {
beforeEach(() => {
mockSpawn.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("syncs OpenRouter and opencode-go models", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\nopencode/gpt-5\nopencode-go/custom\n"));
proc.emit("exit", 0);
});
return proc;
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: [{ id: "openai/gpt-4o", name: "GPT-4o", context_length: 128000 }],
}),
}));
const registerProvider = vi.fn();
const log = vi.fn();
const run = syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
authStorage: { getApiKey: vi.fn().mockResolvedValue("key") },
modelRegistry: { registerProvider },
log,
});
await run;
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(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Synced"));
});
it("respects disabled settings", async () => {
vi.stubGlobal("fetch", vi.fn());
const registerProvider = vi.fn();
await syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: false, opencodeGoModelSync: false }),
authStorage: { getApiKey: vi.fn() },
modelRegistry: { registerProvider },
log: vi.fn(),
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
expect(registerProvider).not.toHaveBeenCalled();
});
it("logs failures and continues", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stderr.emit("data", Buffer.from("provider unavailable"));
proc.emit("exit", 1);
});
return proc;
});
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
const log = vi.fn();
const run = syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) },
modelRegistry: { registerProvider: vi.fn() },
log,
});
await run;
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Failed to sync models"));
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Failed to sync models"));
});
it("parses model ids from opencode CLI output", () => {
expect(parseOpencodeModelsOutput("Models cache refreshed\nopencode/gpt-5\nfoo\nopencode-go/custom\n")).toEqual([
"opencode/gpt-5",
"opencode-go/custom",
]);
});
});

View File

@@ -64,6 +64,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi
import { 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";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
@@ -548,6 +549,13 @@ export async function runDaemon(opts: DaemonOptions = {}) {
modelRegistry.refresh();
}
void syncStartupModels({
getSettings: () => store.getSettings(),
authStorage: dashboardAuthStorage,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
// ── Skills adapter for skills discovery and execution toggling ─────────────
const skillsAdapter = packageManager
? createSkillsAdapter({

View File

@@ -62,6 +62,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"
import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { 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";
// Re-export for backward compatibility with tests
@@ -1351,48 +1352,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
logSink.warn(`Failed to load custom providers from global settings: ${message}`, "custom-providers");
}
// Eagerly sync OpenRouter models — the pi-openrouter-realtime extension
// only registers providers on session_start (TUI-only event), so kick off
// a fetch here so the dashboard model list is populated. Respects the
// openrouterModelSync setting (defaults to true).
(async () => {
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await dashboardAuthStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", { headers });
if (!res.ok) return;
const json = await res.json() as { data?: Array<{ id: string; name: string; context_length?: number; top_provider?: { max_completion_tokens?: number }; pricing?: Record<string, string>; architecture?: { modality?: string; input_modalities?: string[] } }> };
const orModels = (json.data || []).map((m) => {
const id = (m.id || "").toLowerCase();
const name = (m.name || "").toLowerCase();
const reasoning = id.includes(":thinking") || id.includes("-r1") || id.includes("/r1") || id.includes("o1-") || id.includes("o3-") || id.includes("o4-") || id.includes("reasoner") || name.includes("thinking") || name.includes("reasoner");
const hasVision = m.architecture?.input_modalities?.includes("image") ?? m.architecture?.modality?.includes("multimodal") ?? false;
function parseCost(v?: string) { const n = parseFloat(v || "0"); return isNaN(n) ? 0 : n * 1_000_000; }
return {
id: m.id,
name: m.name || m.id,
reasoning,
input: (hasVision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
cost: { input: parseCost(m.pricing?.prompt), output: parseCost(m.pricing?.completion), cacheRead: parseCost(m.pricing?.input_cache_read), cacheWrite: parseCost(m.pricing?.input_cache_write) },
contextWindow: m.context_length || 128000,
maxTokens: m.top_provider?.max_completion_tokens || 16384,
};
});
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models: orModels,
});
logSink.log(`Synced ${orModels.length} models from OpenRouter API`, "openrouter");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.log(`Failed to sync models: ${message}`, "openrouter");
}
})();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logSink.log(`Failed to discover extensions: ${message}`, "extensions");
@@ -1400,6 +1359,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
modelRegistry.refresh();
}
void syncStartupModels({
getSettings: () => store.getSettings(),
authStorage: dashboardAuthStorage,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
registerHandler(store, "settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;

View File

@@ -64,6 +64,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 { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -618,83 +619,6 @@ export async function runServe(
console.warn(`[custom-providers] Failed to load custom providers from global settings: ${message}`);
}
(async () => {
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await dashboardAuthStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers,
});
if (!res.ok) return;
const json = (await res.json()) as {
data?: Array<{
id: string;
name: string;
context_length?: number;
top_provider?: { max_completion_tokens?: number };
pricing?: Record<string, string>;
architecture?: {
modality?: string;
input_modalities?: string[];
};
}>;
};
const orModels = (json.data || []).map((m) => {
const id = (m.id || "").toLowerCase();
const name = (m.name || "").toLowerCase();
const reasoning =
id.includes(":thinking") ||
id.includes("-r1") ||
id.includes("/r1") ||
id.includes("o1-") ||
id.includes("o3-") ||
id.includes("o4-") ||
id.includes("reasoner") ||
name.includes("thinking") ||
name.includes("reasoner");
const hasVision =
m.architecture?.input_modalities?.includes("image") ??
m.architecture?.modality?.includes("multimodal") ??
false;
function parseCost(v?: string) {
const n = parseFloat(v || "0");
return isNaN(n) ? 0 : n * 1_000_000;
}
return {
id: m.id,
name: m.name || m.id,
reasoning,
input: (hasVision ? ["text", "image"] : ["text"]) as (
| "text"
| "image"
)[],
cost: {
input: parseCost(m.pricing?.prompt),
output: parseCost(m.pricing?.completion),
cacheRead: parseCost(m.pricing?.input_cache_read),
cacheWrite: parseCost(m.pricing?.input_cache_write),
},
contextWindow: m.context_length || 128000,
maxTokens: m.top_provider?.max_completion_tokens || 16384,
};
});
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models: orModels,
});
console.log(
`[openrouter] Synced ${orModels.length} models from OpenRouter API`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.log(`[openrouter] Failed to sync models: ${message}`);
}
})();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[extensions] Failed to discover extensions: ${message}`);
@@ -702,6 +626,13 @@ export async function runServe(
modelRegistry.refresh();
}
void syncStartupModels({
getSettings: () => store.getSettings(),
authStorage: dashboardAuthStorage,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
store.on("settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;

View File

@@ -0,0 +1,233 @@
import { spawn } from "node:child_process";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const OPENCODE_MODELS_TIMEOUT_MS = 15_000;
type ModelConfig = {
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
contextWindow: number;
maxTokens: number;
};
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: ModelConfig[];
}) => void;
}
interface AuthStorageLike {
getApiKey: (provider: string) => Promise<string | undefined>;
}
interface SettingsLike {
openrouterModelSync?: boolean;
opencodeGoModelSync?: boolean;
}
interface StartupSyncOptions {
getSettings: () => Promise<SettingsLike>;
authStorage: AuthStorageLike;
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
}
function parseCost(value?: string): number {
const n = parseFloat(value || "0");
return Number.isNaN(n) ? 0 : n * 1_000_000;
}
function toOpenRouterModels(json: {
data?: Array<{
id: string;
name: string;
context_length?: number;
top_provider?: { max_completion_tokens?: number };
pricing?: Record<string, string>;
architecture?: { modality?: string; input_modalities?: string[] };
}>;
}): ModelConfig[] {
return (json.data || []).map((model) => {
const id = (model.id || "").toLowerCase();
const name = (model.name || "").toLowerCase();
const reasoning = id.includes(":thinking")
|| id.includes("-r1")
|| id.includes("/r1")
|| id.includes("o1-")
|| id.includes("o3-")
|| id.includes("o4-")
|| id.includes("reasoner")
|| name.includes("thinking")
|| name.includes("reasoner");
const hasVision = model.architecture?.input_modalities?.includes("image")
?? model.architecture?.modality?.includes("multimodal")
?? false;
return {
id: model.id,
name: model.name || model.id,
reasoning,
input: hasVision ? ["text", "image"] : ["text"],
cost: {
input: parseCost(model.pricing?.prompt),
output: parseCost(model.pricing?.completion),
cacheRead: parseCost(model.pricing?.input_cache_read),
cacheWrite: parseCost(model.pricing?.input_cache_write),
},
contextWindow: model.context_length || 128000,
maxTokens: model.top_provider?.max_completion_tokens || 16384,
};
});
}
async function syncOpenRouterModels(options: StartupSyncOptions): Promise<void> {
const { authStorage, modelRegistry, log } = options;
const apiKey = await authStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(OPENROUTER_MODELS_URL, { headers });
if (!response.ok) {
log("openrouter", `Failed to sync models: HTTP ${response.status}`);
return;
}
const json = await response.json() as {
data?: Array<{
id: string;
name: string;
context_length?: number;
top_provider?: { max_completion_tokens?: number };
pricing?: Record<string, string>;
architecture?: { modality?: string; input_modalities?: string[] };
}>;
};
const models = toOpenRouterModels(json);
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models,
});
log("openrouter", `Synced ${models.length} models from OpenRouter API`);
}
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}`;
return {
id: normalizedId,
name: normalizedId,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
}
export function parseOpencodeModelsOutput(stdout: string): string[] {
const ids = new Set<string>();
const matches = stdout.matchAll(/\bopencode(?:-go)?\/[A-Za-z0-9._:-]+\b/g);
for (const match of matches) {
if (match[0]) {
ids.add(match[0]);
}
}
return [...ids];
}
async function discoverOpencodeGoModels(): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`Timed out after ${OPENCODE_MODELS_TIMEOUT_MS}ms`));
}, OPENCODE_MODELS_TIMEOUT_MS);
proc.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
proc.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
proc.once("exit", (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(stderr.trim() || `opencode exited with code ${code}`));
return;
}
resolve(parseOpencodeModelsOutput(stdout));
});
});
}
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;
}
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`);
}
export async function syncStartupModels(options: StartupSyncOptions): Promise<void> {
const settings = await options.getSettings();
if (settings.openrouterModelSync !== false) {
try {
await syncOpenRouterModels(options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("openrouter", `Failed to sync models: ${message}`);
}
}
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}`);
}
}
}