f887c16309
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.
188 lines
6.6 KiB
JavaScript
188 lines
6.6 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|