/** * @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 * `%` 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…, 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]; } 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(); } /** * 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. */ 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). if (now - failedAt < CHECK_MS) return []; try { cached = { at: now, list: await fetchList() }; } catch (err) { failedAt = now; throw err; } } 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)` ); 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); } if (added.length) console.log(`[pricing] added rates for ${added.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; } /** Test seam: pretend the price list was just fetched. */ function _setCache(list, at = Date.now()) { cached = { at, list }; } module.exports = { syncPricing, syncEnabled, startPricingSync, familyOf, displayName, toRow, _resetCache, _setCache, };