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
+7 -1
View File
@@ -537,7 +537,12 @@ Request body shape:
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
| `GET` | `/api/ai-tools` | The AI Tools catalog (`server/lib/ai-tools.js`: `server/data/ai-tools/*.json` + `DASHBOARD_AI_TOOLS_DIR`) with detection, enabled flag and the effective default. Flags only, never arg arrays. |
| `POST` | `/api/ai-tools/refresh` | Same-origin guarded: merge the login shell's PATH (`server/lib/shell-path.js`), re-detect. |
| `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true}`. `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`. |
| `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true, defaultModel?}` (`""`/`null` clears the default model). `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`/`EBADMODEL`. |
| `GET` | `/api/ai-tools/:id/usage` | `{sessions, cost}` for `sessions.tool = id`. `404 ENOTOOL`. |
| `GET` | `/api/ai-tools/:id/config` | The config files the tool's trusted catalog file lists (`server/lib/ai-tool-config.js`), with `exists`/`format`. |
| `GET` | `/api/ai-tools/:id/config/:index` | One file's `{text, sha}`. `404 ENOFILE`/`ENOENT`, `403 ESECRET`, `413 ETOOBIG`, `415 EBINARY`. |
| `PUT` | `/api/ai-tools/:id/config/:index` | Same-origin guarded `{text, baseSha}`: syntax-checked, backed up, atomic. `409 ESTALE`, `400 EBADSYNTAX`, plus the read errors. |
| `GET` | `/api/ai-tools/:id/models` | The tool's own model list (`server/lib/ai-tool-models.js`: Codex's `models_cache.json`, Claude's aliases + priced versions, a catalog tool's vendor rows + the models its sessions used) and its `defaultModel`. `404 ENOTOOL`. |
| `POST` | `/api/ai-tools/:id/install` | Same-origin guarded: start the catalog's install job (`server/lib/ai-tool-jobs.js` — `npm install -g <pkg>@latest` with the login shell's npm, or a vendor script downloaded and run with `bash`). `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL`, `409 EINSTALLED`/`ENONPM`/`EBUSY`. |
| `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. |
| `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. |
@@ -1564,6 +1569,7 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version); 0 = off
# Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
+201
View File
@@ -0,0 +1,201 @@
/**
* @file Tests for server/lib/ai-tool-config.js and its routes: a tool's
* config files come only from a TRUSTED catalog file's `config` list, only an
* existing regular text file is read or written, a credential-looking name is
* refused, JSON/JSONC is validated before saving, every save is backed up in
* AgentHub's data dir, and a stale `baseSha` is refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tool-config-"));
const builtin = path.join(root, "builtin");
const custom = path.join(root, "custom");
const home = path.join(root, "home");
const data = path.join(root, "data");
for (const d of [builtin, custom, home, data]) fs.mkdirSync(d, { recursive: true });
const saved = { home: process.env.HOME, data: process.env.DASHBOARD_DATA_DIR };
process.env.HOME = home;
process.env.DASHBOARD_DATA_DIR = data;
process.env.DASHBOARD_DB_PATH = path.join(root, "test.db");
const aiTools = require("../lib/ai-tools");
const cfg = require("../lib/ai-tool-config");
const write = (dir, doc) => fs.writeFileSync(path.join(dir, `${doc.id}.json`), JSON.stringify(doc));
const tool = (id, config) => ({
id,
label: id,
detect: [id],
prompt: { mode: "argv" },
config,
});
let server;
let base;
before(async () => {
write(
builtin,
tool("gem", [
{ path: "~/.gem/settings.json" },
{ path: "~/.gem/opencode.jsonc" },
{ path: "~/.gem/GEMINI.md" },
{ path: "~/.gem/missing.json" },
])
);
write(builtin, tool("leaky", [{ path: "~/.leaky/oauth_creds.json" }]));
write(builtin, tool("escape", [{ path: "~/../etc/passwd" }]));
write(custom, tool("untrusted", [{ path: "~/.u/settings.json" }]));
aiTools.__setCatalogDirs([builtin, custom]);
fs.mkdirSync(path.join(home, ".gem"));
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
const express = require("express");
const app = express();
app.use(express.json({ limit: "2mb" }));
app.use("/api/ai-tools", require("../routes/ai-tools"));
server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}/api/ai-tools`;
});
after(() => {
server?.close();
aiTools.__setCatalogDirs(null);
if (saved.home === undefined) delete process.env.HOME;
else process.env.HOME = saved.home;
if (saved.data === undefined) delete process.env.DASHBOARD_DATA_DIR;
else process.env.DASHBOARD_DATA_DIR = saved.data;
fs.rmSync(root, { recursive: true, force: true });
});
describe("catalog `config` field", () => {
it("is honoured only in a trusted file, and never for a credential or an escaping path", () => {
assert.equal(aiTools.has("gem"), true);
assert.equal(aiTools.has("untrusted"), false);
assert.equal(aiTools.has("leaky"), false);
assert.equal(aiTools.has("escape"), false);
});
});
describe("listConfig / readConfig / writeConfig", () => {
it("lists the declared files with their format and whether they exist", () => {
const files = cfg.listConfig("gem");
assert.deepEqual(
files.map((f) => [f.index, f.format, f.exists]),
[
[0, "json", true],
[1, "jsonc", true],
[2, "text", true],
[3, "json", false],
]
);
assert.equal(files[0].path, path.join(home, ".gem/settings.json"));
});
it("reads an existing file with its sha, and refuses a missing one", () => {
const r = cfg.readConfig("gem", 0);
assert.equal(r.text, '{"theme":"dark"}\n');
assert.match(r.sha, /^[0-9a-f]{40}$/);
assert.throws(() => cfg.readConfig("gem", 3), { code: "ENOENT" });
assert.throws(() => cfg.readConfig("gem", 9), { code: "ENOFILE" });
});
it("refuses invalid JSON and JSONC, accepts JSONC comments and trailing commas", () => {
const { sha } = cfg.readConfig("gem", 0);
assert.throws(() => cfg.writeConfig("gem", 0, "{nope", sha), { code: "EBADSYNTAX" });
const c = cfg.readConfig("gem", 1);
cfg.writeConfig("gem", 1, '{\n // keep\n "a": 2, /* x */\n}\n', c.sha);
assert.throws(() => cfg.writeConfig("gem", 1, '{"a": }', cfg.readConfig("gem", 1).sha), {
code: "EBADSYNTAX",
});
});
it("saves over the sha it was opened at, backs up the old text and keeps the mode", () => {
const { sha } = cfg.readConfig("gem", 0);
const out = cfg.writeConfig("gem", 0, '{"theme":"light"}\n', sha);
const file = path.join(home, ".gem/settings.json");
assert.equal(fs.readFileSync(file, "utf8"), '{"theme":"light"}\n');
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
assert.equal(fs.readFileSync(out.backup, "utf8"), '{"theme":"dark"}\n');
assert.ok(out.backup.startsWith(data));
assert.throws(() => cfg.writeConfig("gem", 0, "{}", sha), { code: "ESTALE" });
});
it("refuses a file that is a symlink to a credential, and a binary file", () => {
const md = path.join(home, ".gem/GEMINI.md");
const secret = path.join(home, ".gem/oauth_creds.json");
fs.writeFileSync(secret, '{"t":1}');
fs.rmSync(md);
fs.symlinkSync(secret, md);
assert.throws(() => cfg.readConfig("gem", 2), { code: "ESECRET" });
fs.rmSync(md);
fs.writeFileSync(md, Buffer.from([0x23, 0x00, 0x01]));
assert.throws(() => cfg.readConfig("gem", 2), { code: "EBINARY" });
fs.writeFileSync(md, "# rules\n");
});
});
describe("routes", () => {
it("lists, reads and saves through /:id/config, answering structured errors", async () => {
const list = await (await fetch(`${base}/gem/config`)).json();
assert.equal(list.files.length, 4);
const r = await (await fetch(`${base}/gem/config/2`)).json();
assert.equal(r.text, "# rules\n");
const put = await fetch(`${base}/gem/config/2`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "# new\n", baseSha: r.sha }),
});
assert.equal(put.status, 200);
const stale = await fetch(`${base}/gem/config/2`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "# x\n", baseSha: r.sha }),
});
assert.equal(stale.status, 409);
assert.equal((await fetch(`${base}/nope/config`)).status, 404);
assert.equal((await fetch(`${base}/gem/config/3`)).status, 404);
});
it("answers a failed write as a JSON 500 and leaves no temp file", async () => {
const dir = path.join(home, ".gem");
const r = await (await fetch(`${base}/gem/config/0`)).json();
fs.chmodSync(dir, 0o500);
try {
const put = await fetch(`${base}/gem/config/0`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "{}", baseSha: r.sha }),
});
assert.equal(put.status, 500);
assert.equal((await put.json()).error.code, "EACCES");
} finally {
fs.chmodSync(dir, 0o700);
}
assert.deepEqual(
fs.readdirSync(dir).filter((n) => n.includes(".agenthub-")),
[]
);
});
it("reports a tool's session count and cost", async () => {
const { db } = require("../db");
db.prepare(
"INSERT INTO sessions (id, status, started_at, tool) VALUES ('s1', 'completed', ?, 'gem')"
).run(new Date().toISOString());
const u = await (await fetch(`${base}/gem/usage`)).json();
assert.equal(u.sessions, 1);
assert.equal(typeof u.cost, "number");
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* @file Tests for server/lib/ai-tool-models.js and the model plumbing around
* it: each tool's model list comes from that tool's own source (Codex's
* models cache, Claude's aliases named after the newest priced version, a
* catalog tool's vendor rows plus the models its sessions used), a model id is
* validated before it reaches an argv, the catalog `model` field is checked,
* and the per-tool default model persists.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tool-models-"));
const builtin = path.join(root, "builtin");
const codexHome = path.join(root, "codex");
const data = path.join(root, "data");
for (const d of [builtin, codexHome, data]) fs.mkdirSync(d);
const saved = {
data: process.env.DASHBOARD_DATA_DIR,
codex: process.env.CODEX_HOME,
db: process.env.DASHBOARD_DB_PATH,
};
process.env.DASHBOARD_DATA_DIR = data;
process.env.CODEX_HOME = codexHome;
process.env.DASHBOARD_DB_PATH = path.join(root, "test.db");
const { db } = require("../db");
const aiTools = require("../lib/ai-tools");
const { listModels } = require("../lib/ai-tool-models");
const write = (name, doc) => fs.writeFileSync(path.join(builtin, name), JSON.stringify(doc));
before(() => {
write("claude.json", {
id: "claude",
label: "Claude Code",
detect: ["claude"],
launcher: "claude",
});
write("codex.json", { id: "codex", label: "Codex", detect: ["codex"], launcher: "codex" });
write("gemini.json", {
id: "gemini",
label: "Gemini",
detect: ["gemini"],
prompt: { mode: "argv" },
model: { flag: "--model", vendor: "google" },
});
write("plain.json", { id: "plain", label: "Plain", detect: ["plain"], prompt: { mode: "argv" } });
write("badflag.json", {
id: "badflag",
label: "Bad",
detect: ["badflag"],
prompt: { mode: "argv" },
model: { flag: "rm -rf" },
});
aiTools.__setCatalogDirs([builtin, path.join(root, "none")]);
const price = db.prepare(
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES (?, ?, 1)"
);
db.prepare("DELETE FROM model_pricing").run();
price.run("claude-sonnet-5-5%", "Claude Sonnet 5.5");
price.run("claude-sonnet-5%", "Claude Sonnet 5");
price.run("claude-opus-5-5%", "Claude Opus 5.5");
price.run("gemini-3.8-flash%", "Gemini 3.8 Flash");
price.run("gpt-6-sol%", "GPT-6 Sol");
db.prepare(
"INSERT INTO sessions (id, status, started_at, tool) VALUES ('g1', 'completed', ?, 'gemini')"
).run(new Date().toISOString());
db.prepare("INSERT INTO token_usage (session_id, model) VALUES ('g1', 'gemini-3.1-pro')").run();
});
after(() => {
aiTools.__setCatalogDirs(null);
for (const [k, env] of [
["data", "DASHBOARD_DATA_DIR"],
["codex", "CODEX_HOME"],
["db", "DASHBOARD_DB_PATH"],
]) {
if (saved[k] === undefined) delete process.env[env];
else process.env[env] = saved[k];
}
fs.rmSync(root, { recursive: true, force: true });
});
describe("listModels", () => {
it("reads Codex's own models cache, listed ones only, and nothing else from it", () => {
fs.writeFileSync(
path.join(codexHome, "models_cache.json"),
JSON.stringify({
identity: { email: "someone@example.com" },
models: [
{ slug: "gpt-5.5", display_name: "GPT-5.5", visibility: "list", priority: 12 },
{ slug: "gpt-hidden", display_name: "Hidden", visibility: "hide", priority: 1 },
{
slug: "gpt-6-sol",
display_name: "GPT-6-Sol",
description: "Top",
visibility: "list",
priority: 2,
},
],
})
);
const out = listModels("codex", db);
assert.equal(out.flag, true);
assert.deepEqual(
out.models.map((m) => m.id),
["gpt-6-sol", "gpt-5.5"]
);
assert.equal(out.models[0].label, "GPT-6-Sol");
assert.equal(out.models[0].hint, "Top");
assert.doesNotMatch(JSON.stringify(out), /someone@example\.com/);
});
it("answers an empty Codex list when the cache is missing or unreadable", () => {
fs.writeFileSync(path.join(codexHome, "models_cache.json"), "{nope");
assert.deepEqual(listModels("codex", db).models, []);
});
it("names Claude's aliases after the newest priced version and lists the versions", () => {
const out = listModels("claude", db);
const sonnet = out.models.find((m) => m.id === "sonnet");
assert.match(sonnet.label, /5\.5/);
const ids = out.models.map((m) => m.id);
assert.ok(ids.includes("claude-sonnet-5-5"));
assert.ok(ids.includes("claude-opus-5-5"));
assert.ok(ids.indexOf("claude-sonnet-5-5") < ids.indexOf("claude-sonnet-5"));
});
it("gives a catalog tool its vendor's priced models plus those its sessions used", () => {
const out = listModels("gemini", db);
assert.equal(out.flag, true);
const ids = out.models.map((m) => m.id);
assert.ok(ids.includes("gemini-3.8-flash"));
assert.ok(ids.includes("gemini-3.1-pro"));
assert.ok(!ids.includes("gpt-6-sol"));
});
it("offers no picker to a tool whose catalog declares no model flag", () => {
assert.deepEqual(listModels("plain", db), { flag: false, models: [] });
assert.equal(aiTools.has("badflag"), false);
});
});
describe("model ids and the default model", () => {
it("accepts real model ids and refuses anything flag- or shell-shaped", () => {
for (const ok of [
"opus",
"opus[1m]",
"claude-sonnet-5-5",
"openai/gpt-5.5",
"gemini-3.1-pro",
]) {
assert.equal(aiTools.validModel(ok), true, ok);
}
for (const bad of [
"-p",
"--dangerously-skip-permissions",
"a b",
"x;rm",
"",
"a".repeat(200),
]) {
assert.equal(aiTools.validModel(bad), false, bad);
}
});
it("persists a default model per tool next to enable/default, and clears it", () => {
aiTools.setDefaultModel("gemini", "gemini-3.8-flash");
aiTools.setEnabled("gemini", false);
aiTools.setEnabled("gemini", true);
assert.equal(
aiTools.view().tools.find((t) => t.id === "gemini").defaultModel,
"gemini-3.8-flash"
);
assert.equal(aiTools.view().tools.find((t) => t.id === "gemini").modelFlag, true);
assert.throws(() => aiTools.setDefaultModel("gemini", "--x"), { code: "EBADMODEL" });
aiTools.setDefaultModel("gemini", null);
assert.equal(aiTools.view().tools.find((t) => t.id === "gemini").defaultModel, null);
});
});
+19
View File
@@ -82,6 +82,25 @@ describe("/api/ai-tools", () => {
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
});
it("answers a tool's model choices and keeps its default model", async () => {
let r = await request("GET", "/api/ai-tools/antigravity/models");
assert.equal(r.status, 200);
assert.equal(r.body.flag, true);
assert.equal(r.body.defaultModel, null);
r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "gemini-3.1-pro" });
assert.equal(r.status, 200);
assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, "gemini-3.1-pro");
r = await request("GET", "/api/ai-tools/antigravity/models");
assert.equal(r.body.defaultModel, "gemini-3.1-pro");
assert.equal(
(await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "-x" })).status,
400
);
r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "" });
assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, null);
assert.equal((await request("GET", "/api/ai-tools/nope/models")).status, 404);
});
it("refuses a cross-origin write", async () => {
const r = await request(
"PUT",
+4
View File
@@ -196,6 +196,10 @@ describe("enable / default", () => {
resume: false,
bypass: false,
routerCapable: false,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
install: null,
update: null,
});
@@ -0,0 +1,42 @@
/**
* @file Two dashboards started at the same instant on a FRESH database must
* both come up: server/db.js runs its probe-then-ALTER migrations under one
* cross-process lock, so the second waits and then finds every column already
* there instead of dying on "duplicate column name".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const DB_MODULE = path.join(__dirname, "..", "db.js");
function openDb(dbPath, dataDir) {
return new Promise((resolve) => {
const child = spawn(process.execPath, ["-e", `require(${JSON.stringify(DB_MODULE)})`], {
env: { ...process.env, DASHBOARD_DB_PATH: dbPath, DASHBOARD_DATA_DIR: dataDir },
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr.on("data", (d) => (err += d));
child.on("exit", (code) => resolve({ code, err }));
});
}
it("lets several processes open one fresh database at once", async () => {
for (let round = 0; round < 3; round++) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "db-concurrent-"));
try {
const results = await Promise.all(
Array.from({ length: 4 }, () => openDb(path.join(dir, "dashboard.db"), dir))
);
for (const r of results) assert.equal(r.code, 0, r.err.split("\n").slice(0, 6).join("\n"));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
+84
View File
@@ -344,6 +344,90 @@ describe("/api/lanes", () => {
});
});
describe("liveness of a just-started run", () => {
// A new run keeps the lane's PREVIOUS session_id until the new session's
// first hook rebinds it, and that session may have been silent for days —
// judged by it alone, a run started a second ago read "dead" until a
// refresh (and stayed so for a resume, whose hooks change no lane field).
it("ages the lane from the newer of its last event and its run's start", async () => {
const tmux = require("../lib/tmux");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-live-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const runId = `agenthub-lane-${id}`;
const old = new Date(Date.now() - 3 * 86400e3).toISOString();
try {
tmux.__setExecImpl(() => "");
db.prepare(
"INSERT INTO sessions (id, status, started_at, cwd) VALUES ('sess-old-run', 'completed', ?, ?)"
).run(old, cwd);
db.prepare(
"INSERT INTO events (session_id, event_type, summary, created_at) VALUES ('sess-old-run', 'Stop', '', ?)"
).run(old);
lanesLib.updateLane(id, { session_id: "sess-old-run", run_id: runId, status: "running" });
const runAt = db.prepare(
"INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, started_at, lane_id) VALUES (?, 'interactive', ?, 'running', ?, ?)"
);
runAt.run(runId, cwd, new Date().toISOString(), id);
const fresh = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(fresh.liveness, "active");
runAt.run(runId, cwd, old, id);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "dead");
// A lane no hook ever binds a session to (a catalog tool, Codex without
// its hooks) has no signal at all, and stays "active" however long it runs.
lanesLib.updateLane(id, { session_id: null });
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "active");
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lanes changed by another server on the same DB", () => {
// Hooks go to ONE server per database (the lowest port), so a browser on the
// other one never heard about a lane the hooks moved until it refreshed.
it("re-broadcasts a lane another connection changed, and a removed one", async () => {
const Database = require("better-sqlite3");
const { broadcastForeignLaneChanges } = require("../routes/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-foreign-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const other = new Database(TEST_DB);
try {
broadcastForeignLaneChanges(); // prime what this server has seen
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other
.prepare("UPDATE lanes SET status = 'running', updated_at = ? WHERE id = ?")
.run(new Date(Date.now() + 5).toISOString(), id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [id], removed: [] });
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other.prepare("DELETE FROM lanes WHERE id = ?").run(id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [id] });
} finally {
other.close();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lane start input", () => {
it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
const r = await request("POST", `/api/lanes/${id}/start`, { model: "--dangerous" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADMODEL");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {
+194 -14
View File
@@ -1,8 +1,10 @@
/**
* @file Tests for the automatic pricing sync: a model seen in token_usage with
* no row of its own gets one from the price list, an edited row is never
* overwritten, a bad list or a failed fetch changes nothing, and an OpenAI
* model (a Codex session's) is priced from its `openai` entry too.
* no row of its own gets one from the price list, a current model gets one
* before it is used, the sync's own rows follow a rate change while a user's
* row and a deleted pattern are never touched, a bad list or a failed fetch
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
* `openai` entry too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +25,7 @@ const {
toRow,
_resetCache,
_setCache,
isCurrent,
} = require("../lib/pricing-sync");
// The real LiteLLM entry for Opus 5.5, trimmed.
@@ -52,11 +55,20 @@ beforeEach(() => {
_resetCache();
db.prepare("DELETE FROM token_usage").run();
db.prepare(
"DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%'"
"DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%' OR model_pattern LIKE 'gemini-9%' OR model_pattern LIKE 'gpt-4o%'"
).run();
db.prepare("DELETE FROM model_pricing_dismissed").run();
});
describe("pricing sync", () => {
it("never adds a retired Claude model in advance, only once it is used", () => {
const today = "2026-10-01";
for (const k of ["claude-opus-4-1", "claude-opus-4", "claude-sonnet-4"]) {
assert.equal(isCurrent(k, { mode: "chat" }, today), false, k);
}
assert.equal(isCurrent("claude-opus-4-5", { mode: "chat" }, today), true);
});
it("reduces an id to its family and names it", () => {
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
assert.equal(familyOf("gpt-5.6-sol"), "gpt-5.6-sol");
@@ -110,22 +122,137 @@ describe("pricing sync", () => {
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
});
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => {
it("never overwrites a row the user priced, and fetches once a day", async () => {
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)"
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, source) VALUES ('claude-opus-9-9%', 'Mine', 1, 'user')"
).run();
useModel("claude-opus-9-9");
let fetched = false;
await syncPricing(db, {
fetchList: async () => {
fetched = true;
return LIST;
},
});
assert.equal(fetched, false);
let calls = 0;
const counting = async () => {
calls += 1;
return LIST;
};
await syncPricing(db, { fetchList: counting });
await syncPricing(db, { fetchList: counting, now: Date.now() + 60 * 60_000 });
assert.equal(calls, 1);
await syncPricing(db, { fetchList: counting, now: Date.now() + 25 * 60 * 60_000 });
assert.equal(calls, 2);
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
});
it("adds a current model before it is ever used, and skips old or non-chat ones", async () => {
const chat = (extra = {}) => ({
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
...extra,
});
const list = {
"claude-opus-9-9": { ...LIST["claude-opus-9-9"], mode: "chat" },
"gpt-9.1": chat(),
"gpt-9.1-audio": chat(),
"gpt-9.1-2026-01-01": chat(),
"gpt-9.2": chat({ mode: "embedding" }),
"gpt-9.3": chat({ deprecation_date: "2020-01-01" }),
"gpt-4o": chat(),
"gemini-9.1-pro": chat({ litellm_provider: "gemini" }),
};
const added = await syncPricing(db, { fetchList: async () => list });
assert.deepEqual(added.sort(), ["claude-opus-9-9%", "gemini-9.1-pro%", "gpt-9.1%"]);
assert.equal(row("gpt-9.1%").source, "sync");
assert.equal(row("gpt-4o%"), undefined);
});
it("follows a price change on its own rows, never on the user's", async () => {
const entry = (input) => ({
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: input,
output_cost_per_token: 8e-6,
});
await syncPricing(db, { fetchList: async () => ({ "gpt-9.1": entry(1e-6) }) });
assert.equal(row("gpt-9.1%").input_per_mtok, 1);
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, source) VALUES ('gpt-9.2%', 'Mine', 7, 8, 'user')"
).run();
_resetCache();
await syncPricing(db, {
fetchList: async () => ({ "gpt-9.1": entry(2e-6), "gpt-9.2": entry(2e-6) }),
});
assert.equal(row("gpt-9.1%").input_per_mtok, 2);
assert.equal(row("gpt-9.2%").input_per_mtok, 7);
});
it("adopts a row from before the marker only while it still equals the list", async () => {
const entry = {
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
};
const legacy = db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok) VALUES (?, ?, ?, ?, ?)"
);
legacy.run("gpt-9.1%", "GPT-9.1", 1, 8, 0.1); // what the old sync inserted
legacy.run("gpt-9.2%", "Edited", 5, 8, 0.1); // edited since
await syncPricing(db, { fetchList: async () => ({ "gpt-9.1": entry, "gpt-9.2": entry }) });
assert.equal(row("gpt-9.1%").source, "sync");
assert.equal(row("gpt-9.2%").source, null);
assert.equal(row("gpt-9.2%").input_per_mtok, 5);
});
it("keeps a deleted row deleted", async () => {
db.prepare("INSERT INTO model_pricing_dismissed (model_pattern) VALUES ('gpt-9.1%')").run();
useModel("gpt-9.1");
const list = {
"gpt-9.1": {
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
},
};
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), []);
assert.equal(row("gpt-9.1%"), undefined);
});
it("PUT marks a row the user's, DELETE dismisses it, reset forgets the dismissal", async () => {
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/pricing", require("../routes/pricing"));
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
try {
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, source) VALUES ('gpt-9.1%', 'GPT-9.1', 1, 'sync')"
).run();
await fetch(`${base}/api/pricing`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_pattern: "gpt-9.1%",
display_name: "GPT-9.1",
input_per_mtok: 3,
output_per_mtok: 9,
}),
});
assert.equal(row("gpt-9.1%").source, "user");
await fetch(`${base}/api/pricing/${encodeURIComponent("gpt-9.1%")}`, { method: "DELETE" });
assert.ok(
db.prepare("SELECT 1 FROM model_pricing_dismissed WHERE model_pattern = 'gpt-9.1%'").get()
);
await fetch(`${base}/api/settings/reset-pricing`, { method: "POST" });
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM model_pricing_dismissed").get().n, 0);
} finally {
server.close();
}
});
it("skips absurd rates and survives a failed fetch", async () => {
useModel("claude-opus-9-9");
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
@@ -215,6 +342,59 @@ describe("pricing sync", () => {
}
});
it("POST /api/settings/reset-pricing re-adds a current model nobody has used", async () => {
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const saved = process.env.AGENTHUB_PRICING_SYNC;
process.env.AGENTHUB_PRICING_SYNC = "1";
try {
_setCache({
"gpt-9.7": {
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
},
});
// Applied once: the list is cached and current, nothing is missing.
await syncPricing(db);
assert.ok(db.prepare("SELECT 1 FROM model_pricing WHERE model_pattern = 'gpt-9.7%'").get());
const res = await fetch(
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
{ method: "POST" }
);
const body = await res.json();
assert.ok(body.pricing.some((p) => p.model_pattern === "gpt-9.7%"));
} finally {
if (saved === undefined) delete process.env.AGENTHUB_PRICING_SYNC;
else process.env.AGENTHUB_PRICING_SYNC = saved;
server.close();
}
});
it("does not bring a deleted built-in row back at the next start", () => {
const pattern = db
.prepare(
"SELECT model_pattern FROM model_pricing WHERE model_pattern LIKE 'claude-%' LIMIT 1"
)
.get().model_pattern;
db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?").run(pattern);
db.prepare("INSERT OR IGNORE INTO model_pricing_dismissed (model_pattern) VALUES (?)").run(
pattern
);
require("../db").topUpDefaultPricing();
assert.equal(
db.prepare("SELECT 1 FROM model_pricing WHERE model_pattern = ?").get(pattern),
undefined
);
db.prepare("DELETE FROM model_pricing_dismissed WHERE model_pattern = ?").run(pattern);
});
it("POST /api/settings/reset-pricing with scope claude keeps every other row", async () => {
const express = require("express");
const http = require("node:http");
+12 -2
View File
@@ -439,7 +439,7 @@ describe("pty-run", () => {
return i < 0 ? undefined : call[i].slice(key.length + 1);
};
it("starts antigravity by absolute path, dropping flags it has no use for", () => {
it("starts antigravity by absolute path, with its model flag and none it has no use for", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 13,
@@ -451,7 +451,7 @@ describe("pty-run", () => {
initialPrompt: "hello",
})
);
assert.deepEqual(paneArgv(call), [at("agy"), "-i", "hello"]);
assert.deepEqual(paneArgv(call), [at("agy"), "--model", "opus", "-i", "hello"]);
assert.equal(envOf(call, "PATH"), process.env.PATH);
tmux.__reset();
@@ -467,6 +467,16 @@ describe("pty-run", () => {
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
});
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
for (const tool of ["claude", "codex", "antigravity"]) {
assert.throws(
() => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", tool, model: "--yolo" }),
{ code: "EBADMODEL" },
tool
);
}
});
it("builds each prompt mode, with fixed args first", () => {
const argvFor = (tool, extra = {}) =>
paneArgv(
+13 -1
View File
@@ -8,6 +8,10 @@
"mode": "flag",
"flag": "-i"
},
"model": {
"flag": "--model",
"vendor": "google"
},
"resume": [
"--continue"
],
@@ -15,5 +19,13 @@
"--dangerously-skip-permissions"
],
"homepageUrl": "https://antigravity.google/docs/cli/overview",
"faviconDomain": "antigravity.google"
"faviconDomain": "antigravity.google",
"config": [
{
"path": "~/.gemini/GEMINI.md"
},
{
"path": "~/.gemini/antigravity-cli/keybindings.json"
}
]
}
+9 -1
View File
@@ -7,6 +7,9 @@
"prompt": {
"mode": "argv"
},
"model": {
"flag": "--model"
},
"bypass": [
"--yolo"
],
@@ -18,5 +21,10 @@
},
"update": {
"kind": "script"
}
},
"config": [
{
"path": "~/.cursor/cli-config.json"
}
]
}
+14 -1
View File
@@ -8,6 +8,10 @@
"mode": "flag",
"flag": "--prompt-interactive"
},
"model": {
"flag": "--model",
"vendor": "google"
},
"bypass": [
"--yolo"
],
@@ -19,5 +23,14 @@
},
"update": {
"kind": "npm"
}
},
"config": [
{
"path": "~/.gemini/settings.json",
"format": "jsonc"
},
{
"path": "~/.gemini/GEMINI.md"
}
]
}
+13 -1
View File
@@ -8,6 +8,9 @@
"mode": "flag",
"flag": "--prompt"
},
"model": {
"flag": "--model"
},
"homepageUrl": "https://opencode.ai/docs/cli/",
"install": {
"kind": "npm",
@@ -15,5 +18,14 @@
},
"update": {
"kind": "npm"
}
},
"config": [
{
"path": "~/.config/opencode/opencode.json",
"format": "jsonc"
},
{
"path": "~/.config/opencode/opencode.jsonc"
}
]
}
+13 -1
View File
@@ -8,6 +8,9 @@
"mode": "after-start",
"delayMs": 3000
},
"model": {
"flag": "--model"
},
"bypass": [
"--approval-mode",
"yolo"
@@ -20,5 +23,14 @@
},
"update": {
"kind": "npm"
}
},
"config": [
{
"path": "~/.qwen/settings.json",
"format": "jsonc"
},
{
"path": "~/.qwen/QWEN.md"
}
]
}
+47 -14
View File
@@ -125,6 +125,18 @@ fs.mkdirSync(DB_DIR, { recursive: true });
// the richest legacy copy found so existing users keep all their history. The
// source files are never modified or deleted, and an existing canonical DB is
// never overwritten — so this is safe to run on every startup.
// Every migration below is a check-then-act ("column missing? ALTER it"), and
// two dashboards may start on one database at the same instant: both saw the
// column missing and the second died on "duplicate column name". So the whole
// section runs under a cross-process lock — a write transaction on a separate
// tiny SQLite file (not the main DB: several rebuilds below open their own
// BEGIN and turn foreign_keys off, which a surrounding transaction would
// break). The second process waits on it, then finds every probe satisfied;
// a process that dies holding it releases it with its file handle.
const migrationLock = new Database(`${DB_PATH}-migrate.lock`);
migrationLock.exec("PRAGMA busy_timeout = 120000");
migrationLock.exec("BEGIN IMMEDIATE");
migrateLegacyDatabase(DB_PATH);
const db = new Database(DB_PATH);
@@ -835,6 +847,20 @@ try {
db.prepare("ALTER TABLE model_pricing ADD COLUMN intro_until TEXT").run();
}
// Migrate: who owns a pricing row. 'sync' = inserted by the automatic sync
// (server/lib/pricing-sync.js), which keeps it at the published rate; 'user' =
// written through PUT /api/pricing, never touched again; NULL = a row from
// before this column (a seed, an old sync insert or an old edit — the sync
// adopts one only while its rates still equal the list exactly). A deleted
// row's pattern goes to model_pricing_dismissed so the sync does not bring it
// back; a reset forgets the dismissals it covers.
try {
db.prepare("SELECT source FROM model_pricing LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE model_pricing ADD COLUMN source TEXT").run();
}
db.exec("CREATE TABLE IF NOT EXISTS model_pricing_dismissed (model_pattern TEXT PRIMARY KEY)");
// Default model pricing — shared by initial seed + startup top-up + reset endpoint
// Columns: pattern, display_name, input, output, cache_read (hits & refreshes),
// cache_write (5m ephemeral writes), cache_write_1h (1h ephemeral writes),
@@ -869,16 +895,8 @@ const DEFAULT_PRICING = [
// Once-only (guarded by `user_version`) so a user who deliberately re-adds one
// in Settings keeps it. Pruning is safe — these rows only hold rates; historical
// token_usage is untouched.
const RETIRED_PRICING_PATTERNS = [
"claude-opus-4-1%",
"claude-opus-4-2%",
"claude-sonnet-4-2%",
"claude-3-7-sonnet%",
"claude-3-5-sonnet%",
"claude-3-5-haiku%",
"claude-3-haiku%",
"claude-3-opus%",
];
// The list lives in pricing-sync.js, which must not add them back either.
const { RETIRED_PRICING_PATTERNS } = require("./lib/pricing-sync");
// Top-up: insert any default pattern that isn't already present. Preserves
// user edits to existing rows — we only add what's missing, never overwrite.
@@ -903,9 +921,17 @@ const RETIRED_PRICING_PATTERNS = [
db.pragma("user_version = 1");
}
topUpDefaultPricing();
}
/** Insert each default pattern that is neither present nor deleted by the user. */
function topUpDefaultPricing() {
// A pattern the user deleted (model_pricing_dismissed) stays deleted.
const existing = new Set(
db
.prepare("SELECT model_pattern FROM model_pricing")
.prepare(
"SELECT model_pattern FROM model_pricing UNION SELECT model_pattern FROM model_pricing_dismissed"
)
.all()
.map((r) => r.model_pattern)
);
@@ -1373,6 +1399,9 @@ db.prepare(
`
).run();
migrationLock.exec("COMMIT");
migrationLock.close();
const stmts = {
getSession: db.prepare("SELECT * FROM sessions WHERE id = ?"),
listSessions: db.prepare(
@@ -1701,8 +1730,8 @@ const stmts = {
listPricing: db.prepare("SELECT * FROM model_pricing ORDER BY display_name ASC"),
getPricing: db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?"),
upsertPricing: db.prepare(`
INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'user', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(model_pattern) DO UPDATE SET
display_name = excluded.display_name,
input_per_mtok = excluded.input_per_mtok,
@@ -1712,6 +1741,7 @@ const stmts = {
cache_write_1h_per_mtok = excluded.cache_write_1h_per_mtok,
fast_input_per_mtok = excluded.fast_input_per_mtok,
fast_output_per_mtok = excluded.fast_output_per_mtok,
source = 'user',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
`),
// Update ONLY the time-limited introductory rates for an existing row. Kept
@@ -1733,6 +1763,9 @@ const stmts = {
WHERE model_pattern = ?
`),
deletePricing: db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?"),
dismissPricing: db.prepare(
"INSERT OR IGNORE INTO model_pricing_dismissed (model_pattern) VALUES (?)"
),
// Longest pattern wins: `claude-fable-5-1%` and `claude-fable-5%` both match
// `claude-fable-5-1`, and without an explicit order the winner is whichever
// row happened to be inserted first. Most specific pattern is the right one.
@@ -1977,4 +2010,4 @@ const stmts = {
),
};
module.exports = { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing };
module.exports = { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing, topUpDefaultPricing };
+15
View File
@@ -548,6 +548,21 @@ function startBackgroundServices() {
}, HEAL_MS);
if (healTimer.unref) healTimer.unref();
}
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
const LANE_SYNC_MS = Number(process.env.DASHBOARD_LANE_SYNC_MS ?? 2_000);
if (Number.isFinite(LANE_SYNC_MS) && LANE_SYNC_MS > 0) {
const { broadcastForeignLaneChanges } = require("./routes/lanes");
const laneSyncTimer = setInterval(() => {
try {
broadcastForeignLaneChanges();
} catch (err) {
console.warn("lane sync failed:", err.message);
}
}, LANE_SYNC_MS);
if (laneSyncTimer.unref) laneSyncTimer.unref();
}
// Continuous discovery of sessions under ~/.claude/projects. The one-time
// legacy backfill above runs only once (marker-gated), so a project added
// later whose sessions never flow through hooks would otherwise stay invisible
+151
View File
@@ -0,0 +1,151 @@
/**
* @file A catalog tool's own config files, viewed and edited from its
* Settings tab. The files are ONLY those a trusted catalog file lists in
* `config` (built-in or the synced release copy — never a request, never
* `DASHBOARD_AI_TOOLS_DIR`), under the user's home. Only an existing regular
* text file ≤ 1 MB is read or written, and never one whose name (or whose
* symlink target's name) looks like a credential. A save must match the sha
* the editor opened (`ESTALE`), is syntax-checked for JSON/JSONC, backs the
* old text up under `<data dir>/ai-tool-config-backups/<id>/` (newest 10
* kept), and replaces the file atomically keeping its mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const aiTools = require("./ai-tools");
const { getDataDir } = require("./claude-home");
const MAX_BYTES = 1024 * 1024;
const KEEP_BACKUPS = 10;
const SECRET_RE = aiTools.CONFIG_SECRET_RE;
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
const sha = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
const expand = (p) => path.join(os.homedir(), p.slice(2));
/** Declared files with where they live now and whether they exist. */
function listConfig(id) {
const t = aiTools.get(id);
if (!t) throw fail("ENOTOOL", "unknown AI tool");
return t.config.map((c, index) => {
const full = expand(c.path);
let st = null;
try {
st = fs.statSync(full);
} catch {
/* missing */
}
return {
index,
path: full,
format: c.format,
exists: Boolean(st?.isFile()),
size: st?.isFile() ? st.size : null,
};
});
}
/** The declared file at `index`, resolved through any symlink and checked. */
function target(id, index) {
const files = listConfig(id);
const f = Number.isInteger(index) ? files[index] : undefined;
if (!f) throw fail("ENOFILE", "no such config file for this tool");
let real;
try {
real = fs.realpathSync(f.path);
} catch {
throw fail("ENOENT", "the file does not exist");
}
if (SECRET_RE.test(path.basename(real))) throw fail("ESECRET", "refusing a credential file");
const st = fs.statSync(real);
if (!st.isFile()) throw fail("ENOENT", "not a regular file");
if (st.size > MAX_BYTES) throw fail("ETOOBIG", "the file is larger than 1 MB");
return { ...f, real, mode: st.mode & 0o777 };
}
function readConfig(id, index) {
const f = target(id, index);
const buf = fs.readFileSync(f.real);
if (buf.includes(0)) throw fail("EBINARY", "not a text file");
return { path: f.path, format: f.format, text: buf.toString("utf8"), sha: sha(buf) };
}
/** JSONC → JSON: drop comments and trailing commas outside strings. */
function stripJsonc(s) {
let out = "";
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"') {
const start = i;
for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === "\\") i++;
out += s.slice(start, i + 1);
} else if (c === "/" && s[i + 1] === "/") {
while (i < s.length && s[i] !== "\n") i++;
out += "\n";
} else if (c === "/" && s[i + 1] === "*") {
const end = s.indexOf("*/", i + 2);
i = end < 0 ? s.length : end + 1;
} else out += c;
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
function checkSyntax(format, text) {
if (format === "text") return;
try {
JSON.parse(format === "jsonc" ? stripJsonc(text) : text);
} catch (err) {
throw fail("EBADSYNTAX", `not valid ${format.toUpperCase()}: ${err.message}`);
}
}
function backup(id, f, old) {
const dir = path.join(getDataDir(), "ai-tool-config-backups", id);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700); // the backups may hold keys; mode only applies on create
const name = path.basename(f.path);
const file = path.join(dir, `${name}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`);
fs.writeFileSync(file, old, { mode: 0o600 });
const mine = fs
.readdirSync(dir)
.filter((n) => n.startsWith(`${name}.`) && n.endsWith(".bak"))
.sort();
for (const n of mine.slice(0, -KEEP_BACKUPS)) fs.rmSync(path.join(dir, n), { force: true });
return file;
}
function writeConfig(id, index, text, baseSha) {
if (typeof text !== "string") throw fail("EBADFIELD", "text must be a string");
if (Buffer.byteLength(text) > MAX_BYTES) throw fail("ETOOBIG", "the text is larger than 1 MB");
if (text.includes("\0")) throw fail("EBINARY", "not a text file");
const f = target(id, index);
const old = fs.readFileSync(f.real);
if (old.includes(0)) throw fail("EBINARY", "not a text file");
if (baseSha !== sha(old)) throw fail("ESTALE", "the file changed since it was opened");
checkSyntax(f.format, text);
const saved = backup(id, f, old);
// A random name created exclusively (`wx`): an existing file or symlink at
// that path is refused, never written through.
const tmp = path.join(
path.dirname(f.real),
`.${path.basename(f.real)}.agenthub-${crypto.randomBytes(6).toString("hex")}`
);
fs.writeFileSync(tmp, text, { mode: f.mode, flag: "wx" });
try {
fs.chmodSync(tmp, f.mode);
fs.renameSync(tmp, f.real);
} catch (err) {
fs.rmSync(tmp, { force: true });
throw err;
}
return { sha: sha(Buffer.from(text)), backup: saved };
}
module.exports = { listConfig, readConfig, writeConfig };
+115
View File
@@ -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 };
+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,
};
+144 -36
View File
@@ -1,19 +1,19 @@
/**
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
* models the dashboard has seen but has no rate row of its own
* for. Every model id in `token_usage` is reduced to its family (a
* `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix dropped); a family with no
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
* never updated, so a rate the user edited in Settings always wins. "Its own
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
* Opus 5 by the broader `claude-opus-5%` pattern.
*
* The list is fetched only when something is missing, at most once per
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken
* only from its own vendor (`anthropic` for claude-…, `openai` for gpt-…,
* o<n>…, codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id (`clinepass/…`, `Highest`) is left to
* the user.
* models, from LiteLLM's public price list fetched at most once a day. A row
* (`source = 'sync'`) is INSERTED for every family used in `token_usage` with
* no `<family>%` row of its own (a `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix
* dropped; "its own row" is the test — Opus 5.5 was once priced as Opus 5 by
* the broader `claude-opus-5%`) and for every current-generation chat model of
* the three vendors before anyone uses it (`isCurrent`). The sync's own rows
* follow a published rate change; a row from before the `source` column is
* adopted only while it still equals the list; a `'user'` row (edited in
* Settings) and a deleted pattern (`model_pricing_dismissed`) are never
* touched. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken only
* from its own vendor (`anthropic` for claude-…, `openai` for gpt-…, o<n>…,
* codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id
* (`clinepass/…`, `Highest`) is left to the user.
*
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
* modelled — Codex's context window stays under 272k and runs on the default
@@ -134,23 +134,84 @@ function missingFamilies(db) {
return [...out];
}
// ponytail: "current" is a fixed generation floor per vendor, not a release
// date — raise MIN_GEN when a generation retires. Anything older still gets a
// row the moment it is used (missingFamilies).
const MIN_GEN = { openai: 5, gemini: 3 };
const NOT_A_CODING_MODEL =
/(audio|search|tts|realtime|transcribe|image|computer-use|robotics|embedding|moderation|-exp-|latest$)/;
/**
* A price-list key worth a row before anyone uses it: a family id itself (no
* snapshot date), a chat model, not deprecated, of the vendor's current
* generation.
*/
/**
* Retired models (Claude 3.x, Opus 4/4.1, Sonnet 4): out of the defaults and
* pruned once from existing DBs by server/db.js. The sync prices one only when
* it is actually used — never as a "current" model added in advance.
*/
const RETIRED_PRICING_PATTERNS = [
"claude-opus-4-1%",
"claude-opus-4-2%",
"claude-sonnet-4-2%",
"claude-3-7-sonnet%",
"claude-3-5-sonnet%",
"claude-3-5-haiku%",
"claude-3-haiku%",
"claude-3-opus%",
];
// `claude-opus-4-2%` is Opus 4's dated ids (claude-opus-4-20250514): family claude-opus-4.
const RETIRED_FAMILIES = new Set(RETIRED_PRICING_PATTERNS.map((p) => p.replace(/(-2)?%$/, "")));
function isCurrent(key, e, today) {
const fam = familyOf(key);
if (fam !== key || !e || !["chat", "responses"].includes(e.mode)) return false;
if (typeof e.deprecation_date === "string" && e.deprecation_date < today) return false;
if (NOT_A_CODING_MODEL.test(fam) || RETIRED_FAMILIES.has(fam)) return false;
const vendor = vendorOf(fam);
if (vendor === "claude") return !/^claude-\d/.test(fam); // claude-3-5-sonnet naming = retired
const m = fam.match(/^(?:gpt|gemini)-(\d+(?:\.\d+)?)/);
return Boolean(m) && Number(m[1]) >= MIN_GEN[vendor];
}
const RATE_COLS = [
["input", "input_per_mtok"],
["output", "output_per_mtok"],
["read", "cache_read_per_mtok"],
["w5m", "cache_write_per_mtok"],
["w1h", "cache_write_1h_per_mtok"],
["fastIn", "fast_input_per_mtok"],
["fastOut", "fast_output_per_mtok"],
];
const sameRates = (row, db) => RATE_COLS.every(([k, col]) => Math.abs(row[k] - db[col]) < 1e-9);
async function defaultFetch() {
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
return res.json();
}
let appliedAt = null; // cached.at of the list last applied
/**
* One pass. Returns the patterns inserted. Never throws: pricing is a display
* concern and a failed fetch must not reach the server's startup path.
* One pass: fetch the list at most once a day, then (1) add a row for every
* used family with none and every current model of the three vendors, (2)
* keep the sync's own rows (`source = 'sync'`) at the published rate, and (3)
* adopt a row from before the `source` column while it still equals the list.
* A user's row (`'user'`) and a dismissed pattern are never touched. Returns
* the patterns inserted. Never throws: pricing is a display concern and a
* failed fetch must not reach the server's startup path.
*/
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
try {
const missing = missingFamilies(db);
if (missing.length === 0) return [];
if (!cached || now - cached.at > CACHE_MS) {
// A failed fetch is retried at the sync's own cadence, not on every
// caller (the Codex poll asks every 15 s while a session is active).
const stale = !cached || now - cached.at > CACHE_MS;
// The Codex poll calls this every 15 s while a session runs: nothing new
// to apply and nothing missing is the common answer, and it is free.
if (!stale && appliedAt === cached.at && missing.length === 0) return [];
if (stale) {
// A failed fetch is retried at the sync's own cadence, not on every caller.
if (now - failedAt < CHECK_MS) return [];
try {
cached = { at: now, list: await fetchList() };
@@ -159,29 +220,66 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
throw err;
}
}
const list = cached.list || {};
const today = new Date(now).toISOString().slice(0, 10);
const rows = new Map(
db
.prepare("SELECT * FROM model_pricing")
.all()
.map((r) => [r.model_pattern, r])
);
const dismissed = new Set(
db
.prepare("SELECT model_pattern FROM model_pricing_dismissed")
.all()
.map((r) => r.model_pattern)
);
const families = new Set(missing);
for (const [key, e] of Object.entries(list)) if (isCurrent(key, e, today)) families.add(key);
for (const pattern of rows.keys()) {
const fam = pattern.endsWith("%") ? pattern.slice(0, -1) : null;
if (fam && familyOf(fam) === fam) families.add(fam);
}
const insert = db.prepare(
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
fast_input_per_mtok, fast_output_per_mtok, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
);
const update = db.prepare(
`UPDATE model_pricing SET display_name = ?, input_per_mtok = ?, output_per_mtok = ?,
cache_read_per_mtok = ?, cache_write_per_mtok = ?, cache_write_1h_per_mtok = ?,
fast_input_per_mtok = ?, fast_output_per_mtok = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE model_pattern = ? AND source = 'sync'`
);
const adopt = db.prepare(
"UPDATE model_pricing SET source = 'sync' WHERE model_pattern = ? AND source IS NULL"
);
const added = [];
for (const fam of missing) {
const row = toRow(fam, cached.list?.[fam]);
if (!row) continue;
const { changes } = insert.run(
row.pattern,
row.name,
row.input,
row.output,
row.read,
row.w5m,
row.w1h,
row.fastIn,
row.fastOut
);
if (changes) added.push(row.pattern);
}
const changed = [];
db.transaction(() => {
for (const fam of families) {
const pattern = `${fam}%`;
if (dismissed.has(pattern)) continue;
const row = toRow(fam, list[fam]);
if (!row) continue;
const rates = [row.input, row.output, row.read, row.w5m, row.w1h, row.fastIn, row.fastOut];
const have = rows.get(pattern);
if (!have) {
if (insert.run(pattern, row.name, ...rates).changes) added.push(pattern);
} else if (have.source === "sync") {
if (!sameRates(row, have) && update.run(have.display_name, ...rates, pattern).changes) {
changed.push(pattern);
}
} else if (have.source == null && !have.intro_until && sameRates(row, have)) {
adopt.run(pattern);
}
}
})();
appliedAt = cached.at;
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
if (changed.length) console.log(`[pricing] updated rates for ${changed.join(", ")}`);
return added;
} catch (err) {
console.warn("[pricing] sync failed:", err.message);
@@ -202,20 +300,30 @@ function startPricingSync(db) {
function _resetCache() {
cached = null;
failedAt = -Infinity;
appliedAt = null;
}
/** Make the next pass re-apply the cached list (after a reset emptied the table). */
function invalidate() {
appliedAt = null;
}
/** Test seam: pretend the price list was just fetched. */
function _setCache(list, at = Date.now()) {
cached = { at, list };
appliedAt = null;
}
module.exports = {
RETIRED_PRICING_PATTERNS,
syncPricing,
invalidate,
syncEnabled,
startPricingSync,
familyOf,
displayName,
toRow,
isCurrent,
_resetCache,
_setCache,
};
+10 -2
View File
@@ -147,12 +147,15 @@ function buildCodexArgv({
/**
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
* ...bypass?, ...prompt]`. Every piece but the prompt comes from the catalog
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
* catalog declares a model flag). Every piece but the prompt and the
* validated model comes from the catalog
* file, and the prompt is one argv element — or, for `after-start`, nothing
* here: spawnRun pastes it into the pane once the CLI is up.
*/
function buildCatalogArgv(entry, bin, { resumeLast, bypass, initialPrompt }) {
function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
const argv = [bin, ...entry.args];
if (model && entry.model) argv.push(entry.model.flag, model);
if (resumeLast && entry.resume) argv.push(...entry.resume);
if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt;
@@ -239,6 +242,11 @@ function spawnRun(args) {
`permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}`
);
}
// A model is one argv element after a model flag; one starting with `-`
// would be read as a flag of its own.
if (model != null && model !== "" && !aiTools.validModel(model)) {
throw makeErr("EBADMODEL", "model is not a valid model id");
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}
+7
View File
@@ -893,6 +893,13 @@ function createOpenApiSpec() {
type: "number",
description: "Fast-mode output rate (0 = none)",
},
source: {
type: "string",
nullable: true,
enum: ["sync", "user", null],
description:
"sync = kept at LiteLLM's published rate by the automatic sync; user = edited through PUT, never touched by the sync; null = from before this field",
},
updated_at: { type: "string", format: "date-time" },
},
},
+101 -12
View File
@@ -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
View File
@@ -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;
+5 -1
View File
@@ -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 });
});
+5
View File
@@ -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()),