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:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
+80 -2
View File
@@ -103,6 +103,8 @@ function validate(doc, source) {
launcher = doc.launcher;
}
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
const config = configFiles(doc.config, source);
if (!config) return null;
const fields = installFields(doc);
if (!fields) return null;
let prompt = null;
@@ -119,9 +121,20 @@ function validate(doc, source) {
prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000;
}
}
// `model`: how a run picks a model on this CLI. Only a flag name and a
// vendor (which pricing rows to offer as choices) — the value itself is
// the user's, validated by validModel() at spawn time.
let model = null;
if (doc.model !== undefined) {
const m = doc.model;
if (!m || typeof m.flag !== "string" || !/^--?[a-z][a-z0-9-]*$/.test(m.flag)) return null;
if (m.vendor !== undefined && !MODEL_VENDORS.has(m.vendor)) return null;
model = { flag: m.flag, vendor: m.vendor || null };
}
return {
id: doc.id,
label: doc.label.trim(),
model,
detect: doc.detect,
requires: doc.requires || [],
args: doc.args || [],
@@ -137,10 +150,49 @@ function validate(doc, source) {
launcher,
install: fields.install,
update: fields.update,
config,
source,
};
}
/** A name no config editor may open: logins, tokens, keys. */
const CONFIG_SECRET_RE =
/(auth|credential|token|secret|oauth|cookie|passw|api[-_]?key|\.env$|\.pem$|\.key$|id_rsa)/i;
const CONFIG_FORMATS = new Set(["json", "jsonc", "text"]);
/**
* `config`: the tool's own config files its Settings tab may edit — like
* `install`, only from a trusted file. Each is `~/`-relative, never climbs
* out with `..`, and never names a credential. Answers [] when absent, null
* when invalid.
*/
function configFiles(raw, source) {
if (raw === undefined) return [];
if (!TRUSTED.has(source) || !Array.isArray(raw) || raw.length > 8) return null;
const out = [];
for (const c of raw) {
const p = c && c.path;
if (typeof p !== "string" || !p.startsWith("~/") || p.includes("\0")) return null;
if (p.split("/").some((seg) => seg === "..") || CONFIG_SECRET_RE.test(path.basename(p)))
return null;
const format = c.format ?? (/\.jsonc$/.test(p) ? "jsonc" : /\.json$/.test(p) ? "json" : "text");
if (!CONFIG_FORMATS.has(format)) return null;
out.push({ path: p, format });
}
return out;
}
const MODEL_VENDORS = new Set(["claude", "openai", "google"]);
/**
* A model id that may follow a model flag as one argv element: a letter or
* digit first (never `-`, so it can not become a flag of its own), then the
* characters real ids use — `opus[1m]`, `openai/gpt-5.5`, `gemini-3.1-pro`.
*/
function validModel(m) {
return typeof m === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(m);
}
const RANK = { builtin: 0, remote: 1, custom: 2 };
/** `[dir, source]` pairs, lowest precedence first. */
@@ -249,12 +301,17 @@ const statePath = () => path.join(getDataDir(), "ai-tools.json");
function readState() {
try {
const s = JSON.parse(fs.readFileSync(statePath(), "utf8"));
const models = {};
if (s.models && typeof s.models === "object") {
for (const [id, m] of Object.entries(s.models)) if (validModel(m)) models[id] = m;
}
return {
disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [],
default: typeof s.default === "string" ? s.default : null,
models,
};
} catch {
return { disabled: [], default: null };
return { disabled: [], default: null, models: {} };
}
}
@@ -290,6 +347,18 @@ function setDefault(id) {
writeState({ ...readState(), default: id });
}
/** The model a new run of `id` starts with; null clears it (the CLI's own). */
function setDefaultModel(id, model) {
requireTool(id);
if (model !== null && !validModel(model)) {
throw makeErr("EBADMODEL", "not a valid model id");
}
const s = readState();
if (model === null) delete s.models[id];
else s.models[id] = model;
writeState(s);
}
function effectiveDefault() {
const s = readState();
const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id);
@@ -300,7 +369,8 @@ function effectiveDefault() {
/** The GET /api/ai-tools body — flags only, never the arg arrays. */
function view() {
const disabled = new Set(readState().disabled);
const state = readState();
const disabled = new Set(state.disabled);
return {
tools: list().map((t) => {
const d = detect(t.id);
@@ -318,6 +388,11 @@ function view() {
resume: !!t.resume,
bypass: !!(t.bypass || t.bypassEnv),
routerCapable: ROUTER_CAPABLE.has(t.id),
// claude/codex take a model through their own argv builders.
modelFlag: Boolean(t.model || t.launcher),
defaultModel: state.models[t.id] || null,
modelVendor: t.model?.vendor || null,
configFiles: t.config.length,
install: t.install,
update: t.update,
};
@@ -345,11 +420,14 @@ module.exports = {
isEnabled,
setEnabled,
setDefault,
setDefaultModel,
validModel,
effectiveDefault,
view,
validate,
which,
siteOf,
ROUTER_CAPABLE,
CONFIG_SECRET_RE,
__setCatalogDirs,
};