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.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user