/** * @file The model choices a run of one AI tool offers, each from that tool's * own source — never a hardcoded list that goes stale: * - **codex**: Codex's own `$CODEX_HOME/models_cache.json` (what this account * and this Codex build can use), `visibility: "list"` only, by `priority`. * Only slug/name/description are read — the file also holds the account's * identity, which never leaves this module. * - **claude**: the CLI's aliases (`claude --model sonnet` follows the newest * release), each named after the newest version the pricing table knows, * then every priced `claude--` to pin one. * - **a catalog tool with a `model` flag**: its vendor's priced models * (`model.vendor`) plus every model its own sessions reported. * - anything else: no picker. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const aiTools = require("./ai-tools"); const { getCodexHome } = require("./codex-home"); const CLAUDE_ALIASES = [ ["fable", "Fable"], ["opus", "Opus"], ["sonnet", "Sonnet"], ["haiku", "Haiku"], ]; const VENDOR_PREFIX = { claude: "claude-", openai: "gpt-", google: "gemini-" }; /** `claude-sonnet-5-5` → [5, 5]; null for anything not `claude--[-]`. */ function claudeVersion(id, name) { const m = new RegExp(`^claude-${name}-(\\d+)(?:-(\\d+))?$`).exec(id); return m ? [Number(m[1]), Number(m[2] || 0)] : null; } const byVersionDesc = (a, b) => b.v[0] - a.v[0] || b.v[1] - a.v[1]; /** Priced families (`pattern` minus its `%`) that are bare ids, not globs. */ function pricedIds(db) { return db .prepare("SELECT model_pattern, display_name FROM model_pricing") .all() .filter((r) => /^[a-z0-9][a-z0-9.-]*%$/.test(r.model_pattern)) .map((r) => ({ id: r.model_pattern.slice(0, -1), label: r.display_name })); } function codexModels() { try { const cache = JSON.parse( fs.readFileSync(path.join(getCodexHome(), "models_cache.json"), "utf8") ); return (Array.isArray(cache.models) ? cache.models : []) .filter((m) => m && m.visibility === "list" && aiTools.validModel(m.slug)) .sort((a, b) => (a.priority ?? 1e9) - (b.priority ?? 1e9)) .map((m) => ({ id: m.slug, label: typeof m.display_name === "string" ? m.display_name : m.slug, ...(typeof m.description === "string" ? { hint: m.description } : {}), })); } catch { return []; } } function claudeModels(db) { const priced = pricedIds(db); const aliases = []; const pinned = []; for (const [alias, name] of CLAUDE_ALIASES) { const versions = priced .map((p) => ({ ...p, v: claudeVersion(p.id, alias) })) .filter((p) => p.v) .sort(byVersionDesc); const newest = versions[0]; aliases.push({ id: alias, label: newest ? `${name} (latest · ${newest.v[0]}.${newest.v[1]})` : `${name} (latest)`, }); pinned.push(...versions.map((p) => ({ id: p.id, label: p.label }))); } return [...aliases, ...pinned]; } function catalogModels(tool, db) { const out = new Map(); const prefix = tool.model.vendor && VENDOR_PREFIX[tool.model.vendor]; if (prefix) { for (const p of pricedIds(db)) if (p.id.startsWith(prefix)) out.set(p.id, p); } const used = db .prepare( `SELECT DISTINCT t.model FROM token_usage t JOIN sessions s ON s.id = t.session_id WHERE s.tool = ?` ) .all(tool.id); for (const { model } of used) { if (aiTools.validModel(model) && !out.has(model)) out.set(model, { id: model, label: model }); } return [...out.values()].sort((a, b) => b.id.localeCompare(a.id, undefined, { numeric: true })); } /** * @param {string} id AI Tools catalog id * @param {import("better-sqlite3").Database} db * @returns {{flag: boolean, models: {id: string, label: string, hint?: string}[]}} */ function listModels(id, db) { const tool = aiTools.get(id); if (!tool) return { flag: false, models: [] }; if (tool.launcher === "codex") return { flag: true, models: codexModels() }; if (tool.launcher === "claude") return { flag: true, models: claudeModels(db) }; if (tool.model) return { flag: true, models: catalogModels(tool, db) }; return { flag: false, models: [] }; } module.exports = { listModels };