Files
nntrivi2001 f887c16309 feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
2026-10-01 13:39:17 +07:00

116 lines
4.2 KiB
JavaScript

/**
* @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-<name>-<version>` 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ĩ <vinnt@smartgift.vn>
*/
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-<name>-<n>[-<n>]`. */
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 };