From 84ed8b1e1f4992db23d91e62a1c0e0931e1bd907 Mon Sep 17 00:00:00 2001 From: ricatix Date: Sat, 13 Jun 2026 08:38:59 +0700 Subject: [PATCH 1/4] feat: enrich model metadata via models.dev with local cache - src/cache.ts: 24h TTL cache at ~/.cache/opencode-9router-plugin/ - src/models-dev.ts: fetch models.dev/api.json, build flat lookup index - src/model-mapper.ts: map models.dev fields to OpenCode schema, template fallback for combos/unmatched models - src/index.ts: inject resolveModel() instead of empty {} per model - src/cli.ts: show models.dev cache age in check command Models with '/' in ID get real metadata (context, cost, capabilities). Combo models without '/' use minimal template. Falls back to template if models.dev unreachable. --- package-lock.json | 3 ++ src/cache.ts | 42 +++++++++++++++++++++++ src/cli.ts | 11 ++++++ src/index.ts | 5 ++- src/model-mapper.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++ src/models-dev.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/cache.ts create mode 100644 src/model-mapper.ts create mode 100644 src/models-dev.ts 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..d40b45b --- /dev/null +++ b/src/model-mapper.ts @@ -0,0 +1,82 @@ +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.id) entry.id = dev.id; + 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("/"); + if (slashIdx === -1) { + // Combo model — no provider prefix + return { ...TEMPLATE }; + } + + const modelName = fullId.slice(slashIdx + 1); + const devModel = await lookupModel(modelName); + + if (!devModel) { + return { ...TEMPLATE }; + } + + return mapModel(devModel); +} diff --git a/src/models-dev.ts b/src/models-dev.ts new file mode 100644 index 0000000..59748dd --- /dev/null +++ b/src/models-dev.ts @@ -0,0 +1,83 @@ +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; +} + +export async function lookupModel( + modelName: string, +): Promise { + if (!flatIndex) { + flatIndex = await buildIndex(); + } + return flatIndex.get(modelName) ?? null; +} From 3fac290649151eb29d37b532696d685b6904fd2e Mon Sep 17 00:00:00 2001 From: ricatix Date: Sat, 13 Jun 2026 08:49:12 +0700 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20fuzzy=20model=20name=20lookup=20for?= =?UTF-8?q?=20models.dev=20(gpt5.5=20=E2=86=92=20gpt-5.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models-dev.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/models-dev.ts b/src/models-dev.ts index 59748dd..f367dc6 100644 --- a/src/models-dev.ts +++ b/src/models-dev.ts @@ -73,11 +73,30 @@ async function buildIndex(): Promise> { 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 flatIndex.get(modelName) ?? null; + return tryLookup(modelName); } From 6bc2f1ccb14cfc553dd08dbbb06c4daf649a4440 Mon Sep 17 00:00:00 2001 From: ricatix Date: Sat, 13 Jun 2026 08:50:44 +0700 Subject: [PATCH 3/4] fix: hardcode display name as '9Router {fullId}' for all models --- src/model-mapper.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/model-mapper.ts b/src/model-mapper.ts index d40b45b..af070ea 100644 --- a/src/model-mapper.ts +++ b/src/model-mapper.ts @@ -66,17 +66,19 @@ export async function resolveModel( fullId: string, ): Promise { const slashIdx = fullId.indexOf("/"); + let entry: OpenCodeModelEntry; + if (slashIdx === -1) { // Combo model — no provider prefix - return { ...TEMPLATE }; + entry = { ...TEMPLATE }; + } else { + const modelName = fullId.slice(slashIdx + 1); + const devModel = await lookupModel(modelName); + entry = devModel ? mapModel(devModel) : { ...TEMPLATE }; } - const modelName = fullId.slice(slashIdx + 1); - const devModel = await lookupModel(modelName); - - if (!devModel) { - return { ...TEMPLATE }; - } + // Always prefix display name so OpenCode shows "9Router " + entry.name = `9Router ${fullId}`; - return mapModel(devModel); + return entry; } From 83479b7c4bf7b47abdb1994013d8a57c942c337d Mon Sep 17 00:00:00 2001 From: ricatix Date: Sat, 13 Jun 2026 09:21:04 +0700 Subject: [PATCH 4/4] fix: preserve 9router model ids and names Use the discovered full 9router model id for both API id and display name so models.dev metadata cannot remap requests to upstream provider credentials. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/model-mapper.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/model-mapper.ts b/src/model-mapper.ts index af070ea..d0f1549 100644 --- a/src/model-mapper.ts +++ b/src/model-mapper.ts @@ -27,7 +27,6 @@ const TEMPLATE: OpenCodeModelEntry = { function mapModel(dev: ModelsDevModel): OpenCodeModelEntry { const entry: OpenCodeModelEntry = {}; - if (dev.id) entry.id = dev.id; if (dev.name) entry.name = dev.name; if (dev.family) entry.family = dev.family; if (dev.release_date) entry.release_date = dev.release_date; @@ -77,8 +76,8 @@ export async function resolveModel( entry = devModel ? mapModel(devModel) : { ...TEMPLATE }; } - // Always prefix display name so OpenCode shows "9Router " - entry.name = `9Router ${fullId}`; + entry.id = fullId; + entry.name = fullId; return entry; }