4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
/**
|
|
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see
|
|
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
|
|
* 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.
|
|
*
|
|
* A non-Claude model's PRIORITY rates (`*_cost_per_token_priority`) fill the
|
|
* fast columns: Codex's fast/priority service tier is booked as `speed = fast`
|
|
* (`codex-usage.js`), and its flex tier as `service_tier = 'flex'` (half price,
|
|
* like batch, in `ratesForBucket`).
|
|
* ponytail: OpenAI's long-context (>272k) and Gemini's >200k-token rates are
|
|
* not modelled — Codex's context window (258k) stays under 272k and no current
|
|
* Claude model has such a tier; it needs a context-size bucket in token_usage
|
|
* (a primary-key rebuild) if that changes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const PRICE_LIST_URL =
|
|
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
const CHECK_MS = 15 * 60_000;
|
|
const CACHE_MS = 24 * 60 * 60_000;
|
|
/** A per-MTok rate above this is a parse error, not a price. */
|
|
const MAX_PER_MTOK = 1000;
|
|
|
|
let cached = null; // { at, list }
|
|
let failedAt = -Infinity; // last failed fetch
|
|
|
|
const OPENAI = /^(gpt-|o\d|codex-)[a-z0-9.-]*$/;
|
|
const GEMINI = /^gemini-[a-z0-9.-]+$/;
|
|
const PROVIDERS = {
|
|
claude: ["anthropic"],
|
|
gemini: ["vertex_ai-language-models", "gemini"],
|
|
openai: ["openai"],
|
|
};
|
|
const vendorOf = (fam) =>
|
|
fam.startsWith("claude-") ? "claude" : fam.startsWith("gemini-") ? "gemini" : "openai";
|
|
|
|
/**
|
|
* `claude-opus-5-5-20260101` → `claude-opus-5-5`, `gpt-5.4-2026-03-05` →
|
|
* `gpt-5.4`; null for anything else.
|
|
*/
|
|
function familyOf(model) {
|
|
if (typeof model !== "string") return null;
|
|
if (model.startsWith("claude-")) {
|
|
const fam = model.replace(/-\d{8}$/, "");
|
|
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
|
}
|
|
if (model.startsWith("gemini-")) return GEMINI.test(model) ? model : null;
|
|
const fam = model.replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
|
return OPENAI.test(fam) ? fam : null;
|
|
}
|
|
|
|
const cap = (w) => w[0].toUpperCase() + w.slice(1);
|
|
|
|
/**
|
|
* `claude-opus-5-5` → `Claude Opus 5.5`, `gpt-5.6-sol` → `GPT-5.6 Sol`,
|
|
* `gemini-3.8-flash` → `Gemini 3.8 Flash`.
|
|
*/
|
|
function displayName(fam) {
|
|
if (fam.startsWith("gemini-")) return fam.split("-").map(cap).join(" ");
|
|
if (!fam.startsWith("claude-")) {
|
|
const [head, ...rest] = fam.split("-");
|
|
if (head === "gpt" && rest.length) {
|
|
const [ver, ...words] = rest;
|
|
return [`GPT-${ver}`, ...words.map(cap)].join(" ");
|
|
}
|
|
return [head === "codex" ? "Codex" : head, ...rest.map(cap)].join(" ");
|
|
}
|
|
const [, ...parts] = fam.split("-");
|
|
const words = [];
|
|
const nums = [];
|
|
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
|
const name = words.map(cap).join(" ");
|
|
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
|
}
|
|
|
|
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
|
function toRow(fam, e) {
|
|
const claude = fam.startsWith("claude-");
|
|
if (!e || !PROVIDERS[vendorOf(fam)].includes(e.litellm_provider)) return null;
|
|
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
|
const input = per(e.input_cost_per_token);
|
|
const output = per(e.output_cost_per_token);
|
|
// Published multipliers stand in for a field the list omits: Anthropic
|
|
// charges cache writes (1.25x / 2x), OpenAI does not.
|
|
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
|
const w5m = per(
|
|
e.cache_creation_input_token_cost ?? (claude ? e.input_cost_per_token * 1.25 : 0)
|
|
);
|
|
const w1h = per(
|
|
e.cache_creation_input_token_cost_above_1hr ??
|
|
(claude ? e.input_cost_per_token * 2 : (e.cache_creation_input_token_cost ?? 0))
|
|
);
|
|
const rates = [input, output, read, w5m, w1h];
|
|
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
|
if (input === 0 && output === 0) return null;
|
|
const fast = claude && e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
|
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
|
const prio = claude
|
|
? []
|
|
: [per(e.input_cost_per_token_priority), per(e.output_cost_per_token_priority)];
|
|
const prioOk =
|
|
prio.length > 0 && prio.every((v) => Number.isFinite(v) && v > 0 && v <= MAX_PER_MTOK);
|
|
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
|
const r = (n) => Math.round(n * 1e6) / 1e6;
|
|
return {
|
|
pattern: `${fam}%`,
|
|
name: displayName(fam),
|
|
input: r(input),
|
|
output: r(output),
|
|
read: r(read),
|
|
w5m: r(w5m),
|
|
w1h: r(w1h),
|
|
fastIn: fastOk ? r(input * fast) : prioOk ? r(prio[0]) : 0,
|
|
fastOut: fastOk ? r(output * fast) : prioOk ? r(prio[1]) : 0,
|
|
};
|
|
}
|
|
|
|
/** Families used in token_usage that have no `<family>%` row of their own. */
|
|
function missingFamilies(db) {
|
|
const have = new Set(
|
|
db
|
|
.prepare("SELECT model_pattern FROM model_pricing")
|
|
.all()
|
|
.map((r) => r.model_pattern)
|
|
);
|
|
const out = new Set();
|
|
for (const { model } of db.prepare("SELECT DISTINCT model FROM token_usage").all()) {
|
|
const fam = familyOf(model);
|
|
if (fam && !have.has(`${fam}%`)) out.add(fam);
|
|
}
|
|
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: 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);
|
|
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() };
|
|
} catch (err) {
|
|
failedAt = 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, 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 = [];
|
|
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);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function syncEnabled() {
|
|
return !/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "");
|
|
}
|
|
|
|
function startPricingSync(db) {
|
|
if (!syncEnabled()) return;
|
|
void syncPricing(db);
|
|
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
|
}
|
|
|
|
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,
|
|
};
|