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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
123
packages/cli/src/commands/__tests__/startup-model-sync.test.ts
Normal file
123
packages/cli/src/commands/__tests__/startup-model-sync.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
233
packages/cli/src/commands/startup-model-sync.ts
Normal file
233
packages/cli/src/commands/startup-model-sync.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user