diff --git a/package-lock.json b/package-lock.json index 8d74ee8..5da0e6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "opencode-9router-plugin", "version": "0.1.4", "license": "MIT", + "bin": { + "opencode-9router-plugin": "dist/cli.js" + }, "devDependencies": { "@opencode-ai/plugin": "^1.14.48", "@types/node": "^24.10.1", diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..42ec763 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,42 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const CACHE_DIR = path.join(os.homedir(), ".cache", "opencode-9router-plugin"); +const CACHE_FILE = path.join(CACHE_DIR, "models.dev.json"); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +interface CacheEntry { + timestamp: number; + data: Record; +} + +export async function readCache(): Promise | null> { + try { + const raw = await fs.readFile(CACHE_FILE, "utf8"); + const entry: CacheEntry = JSON.parse(raw); + if (Date.now() - entry.timestamp < CACHE_TTL_MS) { + return entry.data; + } + return null; // expired + } catch { + return null; // missing or corrupt + } +} + +export async function writeCache(data: Record): Promise { + await fs.mkdir(CACHE_DIR, { recursive: true }); + const entry: CacheEntry = { timestamp: Date.now(), data }; + const tmp = path.join(CACHE_DIR, `.models.dev.${process.pid}.tmp`); + await fs.writeFile(tmp, JSON.stringify(entry), "utf8"); + await fs.rename(tmp, CACHE_FILE); +} + +export async function getCacheAge(): Promise<{ exists: boolean; ageMs?: number }> { + try { + const stat = await fs.stat(CACHE_FILE); + return { exists: true, ageMs: Date.now() - stat.mtimeMs }; + } catch { + return { exists: false }; + } +} diff --git a/src/cli.ts b/src/cli.ts index 4122b8a..f3219b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { getCacheAge } from "./cache.js"; const PACKAGE_NAME = "opencode-9router-plugin"; const DEFAULT_CONFIG = { $schema: "https://opencode.ai/config.json" }; @@ -293,6 +294,16 @@ async function check(args: Args): Promise { const stderr = result.stderr?.trim() || "command failed or opencode is not on PATH"; console.log(`opencode models 9router: failed - ${stderr}`); } + + // models.dev cache status + const cache = await getCacheAge(); + if (cache.exists && cache.ageMs !== undefined) { + const hours = Math.floor(cache.ageMs / (1000 * 60 * 60)); + const minutes = Math.floor((cache.ageMs % (1000 * 60 * 60)) / (1000 * 60)); + console.log(`models.dev cache: present (${hours}h${minutes}m old, TTL 24h)`); + } else { + console.log("models.dev cache: not found (will fetch on startup)"); + } } async function main(): Promise { diff --git a/src/index.ts b/src/index.ts index 1e2883c..d60188c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import type { Plugin } from "@opencode-ai/plugin"; +import { resolveModel } from "./model-mapper.js"; type AnyCfg = Record; @@ -90,7 +91,9 @@ const plugin: Plugin = async () => { cfg.provider["9router"].models ||= {}; for (const model of discoveredModels) { - cfg.provider["9router"].models[model] ||= {}; + if (!cfg.provider["9router"].models[model]) { + cfg.provider["9router"].models[model] = await resolveModel(model); + } } if (!cfg.model && defaultModel) { diff --git a/src/model-mapper.ts b/src/model-mapper.ts new file mode 100644 index 0000000..d0f1549 --- /dev/null +++ b/src/model-mapper.ts @@ -0,0 +1,83 @@ +import { lookupModel, type ModelsDevModel } from "./models-dev.js"; + +export interface OpenCodeModelEntry { + id?: string; + name?: string; + family?: string; + release_date?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; + cost?: { input: number; output: number }; + limit?: { context: number; output: number }; + modalities?: { input: string[]; output: string[] }; +} + +const TEMPLATE: OpenCodeModelEntry = { + id: undefined, + name: undefined, + family: undefined, + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, +}; + +function mapModel(dev: ModelsDevModel): OpenCodeModelEntry { + const entry: OpenCodeModelEntry = {}; + + if (dev.name) entry.name = dev.name; + if (dev.family) entry.family = dev.family; + if (dev.release_date) entry.release_date = dev.release_date; + if (dev.attachment !== undefined) entry.attachment = dev.attachment; + if (dev.reasoning !== undefined) entry.reasoning = dev.reasoning; + if (dev.temperature !== undefined) entry.temperature = dev.temperature; + if (dev.tool_call !== undefined) entry.tool_call = dev.tool_call; + + if (dev.cost?.input !== undefined && dev.cost?.output !== undefined) { + entry.cost = { input: dev.cost.input, output: dev.cost.output }; + } + + if (dev.limit?.context !== undefined && dev.limit?.output !== undefined) { + entry.limit = { context: dev.limit.context, output: dev.limit.output }; + } + + if (dev.modalities) { + entry.modalities = { + input: dev.modalities.input ?? ["text"], + output: dev.modalities.output ?? ["text"], + }; + } + + return entry; +} + +/** + * Resolve a 9Router model ID (e.g. "mistral/mistral-large-latest") + * to an OpenCode model entry. + * + * - Models with "/" → extract name after "/", lookup in models.dev + * - Models without "/" (combos) → template + * - No match in models.dev → template + */ +export async function resolveModel( + fullId: string, +): Promise { + const slashIdx = fullId.indexOf("/"); + let entry: OpenCodeModelEntry; + + if (slashIdx === -1) { + // Combo model — no provider prefix + entry = { ...TEMPLATE }; + } else { + const modelName = fullId.slice(slashIdx + 1); + const devModel = await lookupModel(modelName); + entry = devModel ? mapModel(devModel) : { ...TEMPLATE }; + } + + entry.id = fullId; + entry.name = fullId; + + return entry; +} diff --git a/src/models-dev.ts b/src/models-dev.ts new file mode 100644 index 0000000..f367dc6 --- /dev/null +++ b/src/models-dev.ts @@ -0,0 +1,102 @@ +import { readCache, writeCache } from "./cache.js"; + +const MODELS_DEV_URL = "https://models.dev/api.json"; + +export interface ModelsDevProvider { + id: string; + name: string; + npm?: string; + models: Record; +} + +export interface ModelsDevModel { + id: string; + name: string; + family?: string; + attachment?: boolean; + reasoning?: boolean; + tool_call?: boolean; + temperature?: boolean; + knowledge?: string; + release_date?: string; + last_updated?: string; + modalities?: { input?: string[]; output?: string[] }; + open_weights?: boolean; + limit?: { context?: number; output?: number }; + cost?: { input?: number; output?: number }; +} + +// Flat index: model_id → ModelsDevModel (across all providers) +let flatIndex: Map | null = null; + +async function buildIndex(): Promise> { + const index = new Map(); + + // Try cache first + const cached = await readCache(); + if (cached) { + for (const [_providerKey, provider] of Object.entries(cached)) { + const p = provider as ModelsDevProvider; + if (p?.models) { + for (const [modelId, model] of Object.entries(p.models)) { + index.set(modelId, model); + } + } + } + return index; + } + + // Fetch fresh + try { + const res = await fetch(MODELS_DEV_URL, { + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + await writeCache(data); + + for (const [_providerKey, provider] of Object.entries(data)) { + const p = provider as ModelsDevProvider; + if (p?.models) { + for (const [modelId, model] of Object.entries(p.models)) { + index.set(modelId, model); + } + } + } + } catch (err) { + console.warn( + "opencode-9router: failed to fetch models.dev, using empty index:", + (err as Error).message, + ); + } + + return index; +} + +/** Normalize model name for lookup: try exact then fuzzy variations */ +function tryLookup(name: string): ModelsDevModel | null { + if (!flatIndex) return null; + + // 1 — Exact match + if (flatIndex.has(name)) return flatIndex.get(name)!; + + // 2 — Insert dash between word and number: gpt5.5 → gpt-5.5 + const dashed = name.replace(/([a-zA-Z])(\d)/, "$1-$2"); + if (dashed !== name && flatIndex.has(dashed)) return flatIndex.get(dashed)!; + + // 3 — Second dash: gpt-5.5 → gpt-5.5 (already has dash) + // Also try: replace dot with dash: gpt5.5 → gpt5-5 + const dotDash = name.replace(/\./g, "-"); + if (dotDash !== name && flatIndex.has(dotDash)) return flatIndex.get(dotDash)!; + + return null; +} + +export async function lookupModel( + modelName: string, +): Promise { + if (!flatIndex) { + flatIndex = await buildIndex(); + } + return tryLookup(modelName); +}