@jovan158/vantage
Version:
See and control what your coding agent does
281 lines (280 loc) • 13.5 kB
JavaScript
// Per-model prices (USD per 1M tokens) for the cost estimate.
//
// Prices are data, not code. Nobody types them in by hand. Each provider's
// official price page is read by a parser of its own:
//
// official pages (Markdown form) ──► parsers (pricing-source.ts) ──┬─► src/pricing-snapshot.ts
// Anthropic, OpenAI, Google │ generated, ships with Vantage
// └─► ~/.vantage/pricing*.json
// written by `vantage pricing update`
//
// At runtime both are merged, and per model the NEWER source wins: a fresh
// `pricing update` beats an old release, and a newer release beats an old
// update. Vantage never fetches prices on its own — metering must not create
// traffic the user did not ask for — so the only network request is the
// explicit `vantage pricing update`.
//
// Lookup is by exact model ID. A model with no price is reported as
// "price unknown" rather than guessed: an earlier version matched name
// prefixes against remembered prices and showed Opus 5.5 at old Opus 4.1
// rates, about 3x too high.
//
// Not modelled: fast mode, the 1.1x US-only inference multiplier, batch.
// Under a subscription (Pro/Max) no per-token cost applies at all — the
// estimate is what the same traffic would cost on the API; the quota line is
// the real signal there (CONCEPT.md §6a).
import fs from "node:fs";
import path from "node:path";
import { vantageHome } from "./home.js";
import { SNAPSHOT, SNAPSHOTS } from "./pricing-snapshot.js";
export const PRICE_PROVIDERS = ["anthropic", "openai", "google"];
// ---------------------------------------------------------------------------
// Table validation — shared by the cache loader and the parser's output, so a
// hand-edited or truncated cache file can never feed the meter bad numbers.
const PRICE_FIELDS = ["input", "cache_write_5m", "cache_write_1h", "cache_read", "output"];
// Column swaps are the realistic way a format change corrupts a table, and
// they break this ordering. It holds for every model on the page without
// assuming the exact multipliers, which are allowed to change. Anthropic
// lists every price for every model (`strict`); OpenAI and Google leave some
// out — no cache discount, no separate write price, output priced like
// input — so there the order only has to hold loosely.
export function plausiblePrice(p, strict = true) {
for (const f of PRICE_FIELDS) {
if (typeof p[f] !== "number" || !Number.isFinite(p[f]) || p[f] <= 0)
return `${f} is not a positive number`;
}
if (strict) {
if (!(p.cache_read < p.input))
return "cache read is not cheaper than input";
if (!(p.input < p.cache_write_5m && p.cache_write_5m < p.cache_write_1h))
return "cache writes are not above input (5m < 1h)";
if (!(p.output > p.input))
return "output is not above input";
}
else {
if (!(p.cache_read <= p.input))
return "cache read is above input";
if (!(p.input <= p.cache_write_5m && p.cache_write_5m <= p.cache_write_1h))
return "cache writes are below input";
if (!(p.output >= p.input))
return "output is below input";
}
return null;
}
// The whole entry: its base prices and, when present, the long-context and
// announced ones.
export function plausibleEntry(p, strict = true) {
const bad = plausiblePrice(p, strict);
if (bad)
return bad;
if (p.long) {
const b = plausiblePrice(p.long, false);
if (b)
return `long context: ${b}`;
if (!(Number.isFinite(p.long.above) && p.long.above > 0))
return "long context: no token limit";
}
if (p.next) {
const b = plausiblePrice(p.next, strict);
if (b)
return `announced prices: ${b}`;
if (Number.isNaN(Date.parse(p.next.from)))
return "announced prices: no valid date";
}
return null;
}
export function validateTable(x) {
const t = x;
if (!t || typeof t !== "object")
throw new Error("not an object");
if (typeof t.source !== "string")
throw new Error("missing source");
if (typeof t.fetchedAt !== "string" || Number.isNaN(Date.parse(t.fetchedAt)))
throw new Error("missing or invalid fetchedAt");
if (!t.models || typeof t.models !== "object")
throw new Error("missing models");
const provider = t.provider ?? "anthropic";
if (!PRICE_PROVIDERS.includes(provider))
throw new Error(`unknown provider ${String(provider)}`);
const models = {};
const base = (p) => ({
input: p.input,
cache_write_5m: p.cache_write_5m,
cache_write_1h: p.cache_write_1h,
cache_read: p.cache_read,
output: p.output,
});
for (const [id, entry] of Object.entries(t.models)) {
const bad = plausibleEntry(entry, provider === "anthropic");
if (bad)
throw new Error(`${id}: ${bad}`);
models[id] = {
name: typeof entry.name === "string" ? entry.name : id,
...base(entry),
...(entry.long ? { long: { above: entry.long.above, ...base(entry.long) } } : {}),
...(entry.next ? { next: { from: entry.next.from, ...base(entry.next) } } : {}),
};
}
return { ...(t.provider ? { provider } : {}), source: t.source, fetchedAt: t.fetchedAt, models };
}
// ---------------------------------------------------------------------------
// Active prices: bundled snapshot + the user's last `pricing update`.
// Per user, not per project: prices do not depend on the repo. Anthropic's
// list keeps the name it had before there were others.
export function pricingCachePath(provider = "anthropic") {
return path.join(vantageHome(), provider === "anthropic" ? "pricing.json" : `pricing-${provider}.json`);
}
export function readPricingCache(file = pricingCachePath()) {
let raw;
try {
raw = fs.readFileSync(file, "utf8");
}
catch {
return { table: null, error: null };
}
try {
return { table: validateTable(JSON.parse(raw)), error: null };
}
catch (err) {
return { table: null, error: `${file}: ${err.message}` };
}
}
export function writePricingCache(table, file = pricingCachePath()) {
fs.mkdirSync(path.dirname(file), { recursive: true });
// Write-then-rename, so a crash never leaves a half-written file behind.
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(table, null, 2) + "\n");
fs.renameSync(tmp, file);
}
// Per model, the newer table wins. Models only one table lists are kept —
// a model that drops off the page (retired) still prices old sessions.
export function mergeTables(bundled, cached) {
if (!cached)
return { ...bundled.models };
const cachedIsNewer = Date.parse(cached.fetchedAt) >= Date.parse(bundled.fetchedAt);
return cachedIsNewer ? { ...bundled.models, ...cached.models } : { ...cached.models, ...bundled.models };
}
function providerPrices(bundled, file) {
const { table: cached, error } = readPricingCache(file);
const asOf = cached && Date.parse(cached.fetchedAt) > Date.parse(bundled.fetchedAt) ? cached.fetchedAt : bundled.fetchedAt;
return { models: mergeTables(bundled, cached), bundled, cached, cacheError: error, asOf };
}
// `bundled` and `file` are Anthropic's; the others come from their own
// snapshot and file (`others` replaces them in tests).
export function loadActivePrices(bundled = SNAPSHOT, file = pricingCachePath(), others = { openai: SNAPSHOTS.openai, google: SNAPSHOTS.google }) {
const anthropic = providerPrices(bundled, file);
const providers = { anthropic };
for (const p of ["openai", "google"]) {
const t = others[p];
providers[p] = t
? providerPrices(t, pricingCachePath(p))
: { models: {}, bundled: { provider: p, source: "", fetchedAt: anthropic.asOf, models: {} }, cached: null, cacheError: null, asOf: anthropic.asOf };
}
const models = Object.assign({}, providers.google.models, providers.openai.models, anthropic.models);
const asOf = PRICE_PROVIDERS.map((p) => providers[p].asOf).sort()[0];
const cacheError = PRICE_PROVIDERS.map((p) => providers[p].cacheError).filter(Boolean).join("; ") || null;
return { models, bundled, cached: anthropic.cached, cacheError, providers, asOf };
}
// Loaded once per process, on first use. A running session keeps the prices
// it started with; `pricing update` applies from the next session on.
let active = null;
export function activePrices() {
return (active ??= loadActivePrices());
}
export function resetActivePrices() {
active = null;
}
// ---------------------------------------------------------------------------
// Lookup and cost.
// Responses may carry a dated snapshot ID ("claude-opus-4-5-20251101",
// "gpt-5.5-2026-08-01") or a context-window tag ("claude-opus-5[1m]"); both
// price like the base model. Gateways put the provider in front
// ("openai/gpt-5", "models/gemini-2.5-pro"), and Copilot writes Claude's
// versions with a dot ("claude-sonnet-4.5").
export function normalizeModelId(model) {
let id = model
.toLowerCase()
.trim()
.replace(/\[[^\]]*\]$/, "")
.replace(/^(?:models|anthropic|openai|google|google-ai-studio|vertex_ai)\//, "")
.replace(/-\d{8}$/, "");
if (id.startsWith("claude-"))
id = id.replace(/(\d)\.(\d)/g, "$1-$2");
return id;
}
export function priceFor(model) {
if (!model)
return null;
const models = activePrices().models;
const lower = model.toLowerCase();
// The exact ID first: a dated snapshot may have a price of its own
// (OpenAI lists gpt-4o-2024-05-13 apart from gpt-4o).
return models[lower] ?? models[normalizeModelId(model)] ?? models[normalizeModelId(model).replace(/-\d{4}-\d{2}-\d{2}$/, "")] ?? null;
}
// The prices that apply to one request: the announced ones once their day
// has come, the long-context ones for a prompt above the limit.
export function pricesForRequest(price, promptTokens, now = Date.now()) {
const current = price.next && now >= Date.parse(price.next.from) ? price.next : price;
if (price.long && promptTokens > price.long.above) {
// An announced change moves the long-context rates by the same factor.
const f = current === price ? 1 : current.input / price.input;
return {
input: price.long.input * f,
cache_write_5m: price.long.cache_write_5m * f,
cache_write_1h: price.long.cache_write_1h * f,
cache_read: price.long.cache_read * f,
output: price.long.output * f,
};
}
return current;
}
// Estimated USD for one request, or null when the model's price is unknown.
export function estimateCostUsd(usage, now = Date.now()) {
const listed = priceFor(usage.model);
if (!listed)
return null;
const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
const price = pricesForRequest(listed, prompt, now);
// cache_creation_input_tokens is the total written; the 1h share (when the
// response reports the split) is billed at the 1h rate, the rest at 5m.
const write1h = Math.min(usage.cache_write_1h_tokens ?? 0, usage.cache_creation_input_tokens);
const write5m = usage.cache_creation_input_tokens - write1h;
return ((usage.input_tokens * price.input +
usage.output_tokens * price.output +
write5m * price.cache_write_5m +
write1h * price.cache_write_1h +
usage.cache_read_input_tokens * price.cache_read) /
1_000_000);
}
// One cost label for every view. The "~" marks it as an estimate.
// `unpriced` counts requests whose model had no known price; their cost is
// NOT in `knownUsd`, and the label says so.
export function formatCost(knownUsd, unpriced, requests) {
if (requests > 0 && unpriced >= requests)
return "cost n/a (price unknown)";
const base = `~$${knownUsd.toFixed(4)}`;
if (unpriced > 0)
return `${base} (${unpriced} request(s) unpriced)`;
return base;
}
// ---------------------------------------------------------------------------
// Hints shown at session end — the moment a missing or old price matters.
export const STALE_AFTER_DAYS = 60;
export function pricingHints(unpricedModels, prices = activePrices(), nowMs = Date.now()) {
const hints = [];
// Families whose maker's list Vantage reads: a newer list may have them.
const listed = (m) => /^(?:claude-|gpt-|o\d|chatgpt-|codex-|gemini-)/.test(normalizeModelId(m));
const known = unpricedModels.filter(listed);
const other = unpricedModels.filter((m) => !listed(m));
if (known.length) {
hints.push(`no price for ${known.join(", ")} — \`vantage pricing update\` fetches the current official lists`);
}
if (other.length) {
hints.push(`no price for ${other.join(", ")} (not on the Anthropic, OpenAI or Google price lists) — tokens are still metered`);
}
const ageDays = Math.floor((nowMs - Date.parse(prices.asOf)) / 86_400_000);
if (ageDays >= STALE_AFTER_DAYS) {
hints.push(`price list is ${ageDays} days old — \`vantage pricing update\` refreshes it`);
}
return hints;
}