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:
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