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:
+80
-2
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user