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