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:
+101
-12
@@ -4,8 +4,8 @@
|
||||
* shell, and enable/disable a tool or make it the default. Nothing here takes
|
||||
* a command or an argument from the request — the catalog files are the only
|
||||
* source of what a lane may run, or an install/update may execute. Also: start
|
||||
* an install/update job and read it back, npm version badges, and the
|
||||
* server-cached icons. Writes sit behind the same-origin guard.
|
||||
* an install/update job and read it back, npm version badges, the
|
||||
* server-cached icons, and each tool's model choices + default model. Writes sit behind the same-origin guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,10 @@ const versions = require("../lib/ai-tool-versions");
|
||||
const { iconFor } = require("../lib/ai-tool-icons");
|
||||
const { hydrateShellPath } = require("../lib/shell-path");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { listModels } = require("../lib/ai-tool-models");
|
||||
const toolConfig = require("../lib/ai-tool-config");
|
||||
const { calculateCost } = require("./pricing");
|
||||
const { db, stmts } = require("../db");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -32,23 +36,30 @@ router.post("/refresh", sameOriginGuard, async (_req, res) => {
|
||||
});
|
||||
|
||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
const { enabled, default: makeDefault } = req.body || {};
|
||||
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
||||
// Every field checked before anything is written: a bad one saves nothing.
|
||||
if (enabled !== undefined && typeof enabled !== "boolean") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "enabled must be a boolean" } });
|
||||
}
|
||||
if (defaultModel && !aiTools.validModel(defaultModel)) {
|
||||
return res.status(400).json({ error: { code: "EBADMODEL", message: "not a model id" } });
|
||||
}
|
||||
try {
|
||||
if (enabled !== undefined) {
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "enabled must be a boolean" } });
|
||||
}
|
||||
aiTools.setEnabled(req.params.id, enabled);
|
||||
if (defaultModel !== undefined) {
|
||||
// "" or null = no default (the CLI's own).
|
||||
aiTools.setDefaultModel(req.params.id, defaultModel || null);
|
||||
}
|
||||
if (enabled !== undefined) aiTools.setEnabled(req.params.id, enabled);
|
||||
if (makeDefault === true) aiTools.setDefault(req.params.id);
|
||||
else if (enabled === undefined && !aiTools.has(req.params.id)) {
|
||||
else if (enabled === undefined && defaultModel === undefined && !aiTools.has(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
|
||||
}
|
||||
res.json(aiTools.view());
|
||||
} catch (e) {
|
||||
const status = e.code === "ENOTOOL" ? 404 : e.code === "EBADDEFAULT" ? 400 : 500;
|
||||
const status =
|
||||
e.code === "ENOTOOL" ? 404 : e.code === "EBADDEFAULT" || e.code === "EBADMODEL" ? 400 : 500;
|
||||
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
});
|
||||
@@ -85,6 +96,84 @@ router.post("/:id/update", sameOriginGuard, (req, res) =>
|
||||
startJob(res, () => jobs.start(req.params.id, "update"))
|
||||
);
|
||||
|
||||
// The model choices a run of this tool offers, from the tool's own source
|
||||
// (server/lib/ai-tool-models.js), plus the default the user picked.
|
||||
router.get("/:id/models", (req, res) => {
|
||||
if (!aiTools.has(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
|
||||
}
|
||||
res.json({
|
||||
...listModels(req.params.id, db),
|
||||
defaultModel: aiTools.readState().models[req.params.id] || null,
|
||||
});
|
||||
});
|
||||
|
||||
const CONFIG_STATUS = {
|
||||
ENOTOOL: 404,
|
||||
ENOFILE: 404,
|
||||
ENOENT: 404,
|
||||
ESECRET: 403,
|
||||
ESTALE: 409,
|
||||
ETOOBIG: 413,
|
||||
EBINARY: 415,
|
||||
EBADSYNTAX: 400,
|
||||
EBADFIELD: 400,
|
||||
};
|
||||
function configRoute(fn) {
|
||||
return (req, res) => {
|
||||
try {
|
||||
res.json(fn(req));
|
||||
} catch (err) {
|
||||
// An unexpected failure (EACCES, a full disk) is a JSON 500 too.
|
||||
res
|
||||
.status(CONFIG_STATUS[err.code] || 500)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
};
|
||||
}
|
||||
const index = (req) => (/^\d+$/.test(req.params.index) ? Number(req.params.index) : -1);
|
||||
|
||||
// The tool's own config files, as its trusted catalog file lists them.
|
||||
router.get(
|
||||
"/:id/config",
|
||||
configRoute((req) => ({ files: toolConfig.listConfig(req.params.id) }))
|
||||
);
|
||||
router.get(
|
||||
"/:id/config/:index",
|
||||
configRoute((req) => toolConfig.readConfig(req.params.id, index(req)))
|
||||
);
|
||||
router.put(
|
||||
"/:id/config/:index",
|
||||
sameOriginGuard,
|
||||
configRoute((req) =>
|
||||
toolConfig.writeConfig(req.params.id, index(req), req.body?.text, req.body?.baseSha)
|
||||
)
|
||||
);
|
||||
|
||||
// How much a tool has been used: its sessions and their priced tokens.
|
||||
router.get("/:id/usage", (req, res) => {
|
||||
if (!aiTools.has(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
|
||||
}
|
||||
const id = req.params.id;
|
||||
const { n } = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE tool = ?").get(id);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT tu.model AS model, tu.speed AS speed, tu.inference_geo AS inference_geo,
|
||||
tu.service_tier AS service_tier,
|
||||
SUM(tu.input_tokens + tu.baseline_input) AS input_tokens,
|
||||
SUM(tu.output_tokens + tu.baseline_output) AS output_tokens,
|
||||
SUM(tu.cache_read_tokens + tu.baseline_cache_read) AS cache_read_tokens,
|
||||
SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens,
|
||||
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens
|
||||
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
|
||||
WHERE s.tool = ?
|
||||
GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier`
|
||||
)
|
||||
.all(id);
|
||||
res.json({ sessions: n, cost: calculateCost(rows, stmts.listPricing.all()).total_cost });
|
||||
});
|
||||
|
||||
router.get("/job", (_req, res) => {
|
||||
res.json(jobs.current());
|
||||
});
|
||||
|
||||
+62
-8
@@ -76,15 +76,29 @@ const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
|
||||
const LOG_TAIL_DEFAULT = 64 * 1024;
|
||||
const LOG_TAIL_MAX = 1024 * 1024;
|
||||
|
||||
/** Seconds since this lane's session last emitted an event; null if never. */
|
||||
/**
|
||||
* Seconds since this lane last showed life: its session's newest event or the
|
||||
* start of its current run, whichever is newer; null if neither exists. The
|
||||
* run counts because a new run keeps the PREVIOUS session_id until the new
|
||||
* session's first hook rebinds it — judged by that old session alone, a run
|
||||
* started a second ago read "dead" and, with no lane field changing after
|
||||
* (a resume, or a hook ingested by another server on the same DB), stayed so
|
||||
* until a refresh. Only with a session bound: a lane no hook ever gives one
|
||||
* (a catalog tool, Codex without its hooks) has no signal, so stays null.
|
||||
*/
|
||||
const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?");
|
||||
const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?");
|
||||
function lastEventAge(lane) {
|
||||
if (!lane.session_id) return null;
|
||||
const row = db
|
||||
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
|
||||
.get(lane.session_id);
|
||||
if (!row || !row.last) return null;
|
||||
const t = Date.parse(row.last);
|
||||
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
const times = [];
|
||||
const row = lastEventStmt.get(lane.session_id);
|
||||
if (row?.last) times.push(Date.parse(row.last));
|
||||
if (lane.run_id) {
|
||||
const run = runStartStmt.get(lane.run_id);
|
||||
if (run?.started_at) times.push(Date.parse(run.started_at));
|
||||
}
|
||||
const t = Math.max(...times.filter((x) => !Number.isNaN(x)));
|
||||
return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +130,43 @@ function healAllRunIds() {
|
||||
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one
|
||||
* database, and hooks reach only one of them (the lowest port), so the other's
|
||||
* browsers never heard about a lane the hooks moved until they refreshed.
|
||||
* `PRAGMA data_version` moves only on another connection's commit, so the
|
||||
* common pass is one pragma; after one, each lane whose `updated_at` differs
|
||||
* from what this pass saw last is broadcast (a lane this server wrote itself
|
||||
* may go out twice — the client drops an identical copy), and a vanished
|
||||
* lane goes out as `removed`.
|
||||
* ponytail: two writes in the same millisecond share an updated_at; a counter
|
||||
* column would close that.
|
||||
*/
|
||||
let seenDataVersion = null;
|
||||
const seenLanes = new Map();
|
||||
const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes");
|
||||
function broadcastForeignLaneChanges() {
|
||||
const out = { changed: [], removed: [] };
|
||||
const version = db.pragma("data_version", { simple: true });
|
||||
if (version === seenDataVersion) return out;
|
||||
const primed = seenDataVersion !== null;
|
||||
seenDataVersion = version;
|
||||
const live = new Set();
|
||||
for (const { id, updated_at } of laneStampsStmt.all()) {
|
||||
live.add(id);
|
||||
if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id);
|
||||
seenLanes.set(id, updated_at);
|
||||
}
|
||||
for (const id of [...seenLanes.keys()]) {
|
||||
if (live.has(id)) continue;
|
||||
seenLanes.delete(id);
|
||||
if (primed) out.removed.push(id);
|
||||
}
|
||||
for (const id of out.changed) broadcastLane(id);
|
||||
for (const id of out.removed) broadcast("lane_update", { removed: id });
|
||||
return out;
|
||||
}
|
||||
|
||||
function payload(lane) {
|
||||
const healed = healRunId(lane);
|
||||
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
||||
@@ -1774,7 +1825,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (err.code === "ETMUXMISSING") {
|
||||
return res.status(503).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
// A refused field (EBADMODEL, EBADEFFORT, …) is the request's fault.
|
||||
const status = /^EBAD/.test(err.code || "") ? 400 : 500;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
broadcastLane(lane.id);
|
||||
@@ -1794,3 +1847,4 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
module.exports = router;
|
||||
module.exports.broadcastLane = broadcastLane;
|
||||
module.exports.healAllRunIds = healAllRunIds;
|
||||
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;
|
||||
|
||||
@@ -369,7 +369,11 @@ router.delete("/:pattern", (req, res) => {
|
||||
.status(404)
|
||||
.json({ error: { code: "NOT_FOUND", message: "Pricing rule not found" } });
|
||||
}
|
||||
stmts.deletePricing.run(pattern);
|
||||
// Remembered so the automatic sync does not add the row straight back.
|
||||
db.transaction(() => {
|
||||
stmts.deletePricing.run(pattern);
|
||||
stmts.dismissPricing.run(pattern);
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -228,8 +228,10 @@ const RESET_SYNC_MS = 3000;
|
||||
router.post("/reset-pricing", async (req, res) => {
|
||||
if (req.body?.scope === "claude") {
|
||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-%'").run();
|
||||
db.prepare("DELETE FROM model_pricing_dismissed WHERE model_pattern LIKE 'claude-%'").run();
|
||||
} else {
|
||||
db.prepare("DELETE FROM model_pricing").run();
|
||||
db.prepare("DELETE FROM model_pricing_dismissed").run();
|
||||
}
|
||||
|
||||
const seedPricing = db.prepare(
|
||||
@@ -242,6 +244,9 @@ router.post("/reset-pricing", async (req, res) => {
|
||||
// carries standard rates, so without this a reset silently drops the promo.
|
||||
applyIntroPricing(db);
|
||||
if (pricingSync.syncEnabled()) {
|
||||
// The rows just deleted include current models nobody has used yet; the
|
||||
// cached list counts as applied, so without this they wait for tomorrow.
|
||||
pricingSync.invalidate();
|
||||
await Promise.race([
|
||||
pricingSync.syncPricing(db),
|
||||
new Promise((r) => setTimeout(r, RESET_SYNC_MS).unref()),
|
||||
|
||||
Reference in New Issue
Block a user