/** * @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 `%` 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…, * 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 * tier; add a service_tier bucket if that changes. Gemini's >200k-token rate * is not modelled either (the base rate is used). * @author Nguyễn Ngọc Trí Vĩ */ 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; // 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) : 0, fastOut: fastOk ? r(output * fast) : 0, }; } /** Families used in token_usage that have no `%` 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, };