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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user