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:
+47
-14
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user