f887c16309
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.
116 lines
4.2 KiB
JavaScript
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 };
|