/** * @file Automatic pricing for Claude 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` 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. * @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 } /** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */ function familyOf(model) { if (typeof model !== "string") return null; const fam = model.replace(/-\d{8}$/, ""); return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null; } /** `claude-opus-5-5` → `Claude Opus 5.5`. */ function displayName(fam) { const [, ...parts] = fam.split("-"); const words = []; const nums = []; for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p); const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).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) { if (!e || e.litellm_provider !== "anthropic") 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); // Anthropic's published multipliers stand in for a field the list omits. 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 ?? e.input_cost_per_token * 1.25); const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2); 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 = 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) cached = { at: now, list: await fetchList() }; 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 startPricingSync(db) { if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return; void syncPricing(db); setInterval(() => void syncPricing(db), CHECK_MS).unref(); } function _resetCache() { cached = null; } module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };