Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
}

export async function readCache(): Promise<Record<string, any> | 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<string, any>): Promise<void> {
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 };
}
}
11 changes: 11 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -293,6 +294,16 @@ async function check(args: Args): Promise<void> {
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<void> {
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Plugin } from "@opencode-ai/plugin";
import { resolveModel } from "./model-mapper.js";

type AnyCfg = Record<string, any>;

Expand Down Expand Up @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions src/model-mapper.ts
Original file line number Diff line number Diff line change
@@ -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<OpenCodeModelEntry> {
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;
}
102 changes: 102 additions & 0 deletions src/models-dev.ts
Original file line number Diff line number Diff line change
@@ -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<string, ModelsDevModel>;
}

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<string, ModelsDevModel> | null = null;

async function buildIndex(): Promise<Map<string, ModelsDevModel>> {
const index = new Map<string, ModelsDevModel>();

// 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<ModelsDevModel | null> {
if (!flatIndex) {
flatIndex = await buildIndex();
}
return tryLookup(modelName);
}