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