@jovan158/vantage
Version:
See and control what your coding agent does
424 lines (423 loc) • 20.4 kB
JavaScript
// Reads the official price lists — the one place prices come from.
//
// Anthropic, OpenAI and Google serve their pricing pages as Markdown too
// (Anthropic and OpenAI: the URL plus ".md"; Google: plus ".md.txt"), so the
// model tables can be read as tables instead of scraped from HTML. Both the
// bundled snapshot (scripts/pricing-snapshot.ts) and `vantage pricing update`
// go through the same parser per provider, so there is one set of checks.
//
// The parser is strict on purpose: columns are found by their header text,
// not position; every price must read "$X / MTok"; every row must pass
// plausiblePrice. A row that fails is skipped and reported; a table that
// yields too few models is rejected as a whole and nothing is written. A
// format change on the page therefore shows up as an error, never as wrong
// numbers in the meter.
import { upstreamTransport } from "./upstream.js";
import { plausibleEntry, plausiblePrice } from "./pricing.js";
export const PRICING_SOURCE_URL = "https://platform.claude.com/docs/en/about-claude/pricing.md";
export const OPENAI_PRICING_URL = "https://developers.openai.com/api/docs/pricing.md";
export const GOOGLE_PRICING_URL = "https://ai.google.dev/gemini-api/docs/pricing.md.txt";
const DEFAULT_URLS = {
anthropic: PRICING_SOURCE_URL,
openai: OPENAI_PRICING_URL,
google: GOOGLE_PRICING_URL,
};
// Overridable for tests and for mirrors in networks without direct access:
// VANTAGE_PRICING_URL (Anthropic), VANTAGE_PRICING_URL_OPENAI,
// VANTAGE_PRICING_URL_GOOGLE.
export function pricingSourceUrl(provider = "anthropic") {
const variable = provider === "anthropic" ? "VANTAGE_PRICING_URL" : `VANTAGE_PRICING_URL_${provider.toUpperCase()}`;
return process.env[variable] || DEFAULT_URLS[provider];
}
export class PricingFormatError extends Error {
}
// A full table has ~20 models; far fewer means the page did not parse.
const MIN_MODELS = 3;
const COLUMNS = {
model: /^model$/i,
input: /base input/i,
cache_write_5m: /5m cache write/i,
cache_write_1h: /1h cache write/i,
cache_read: /cache hit/i,
output: /^output/i,
};
function cells(line) {
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
}
function stripMarkup(s) {
return s
.replace(/<sup>.*?<\/sup>/gi, "")
.replace(/\(\[[^\]]*\]\([^)]*\)\)/g, "") // "([retired, ...](url))"
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // "[text](url)" -> "text"
.replace(/\*\*?/g, "")
.trim();
}
function parsePrice(cell) {
const m = /^\$(\d+(?:\.\d+)?)\s*\/\s*MTok$/i.exec(stripMarkup(cell));
return m ? Number(m[1]) : null;
}
// "Claude Opus 5.5" -> "claude-opus-5-5". Before the 4 generation the
// version came first: "Claude Haiku 3.5" -> "claude-3-5-haiku". This matches
// the IDs listed on the models overview page.
export function modelIdFor(displayName) {
const m = /^Claude ([A-Za-z]+) (\d+(?:\.\d+)?)$/.exec(displayName);
if (!m)
return null;
const family = m[1].toLowerCase();
const version = m[2].replace(".", "-");
return Number(m[2].split(".")[0]) < 4 ? `claude-${version}-${family}` : `claude-${family}-${version}`;
}
export function parsePricingMarkdown(md) {
const lines = md.split(/\r?\n/);
const start = lines.findIndex((l) => /^##\s+Model pricing\s*$/i.test(l));
if (start < 0)
throw new PricingFormatError('section "## Model pricing" not found — the page format changed');
let i = start + 1;
while (i < lines.length && !lines[i].trim().startsWith("|")) {
if (/^##\s/.test(lines[i]))
break;
i++;
}
if (i >= lines.length || !lines[i].trim().startsWith("|")) {
throw new PricingFormatError("no table under \"Model pricing\" — the page format changed");
}
const header = cells(lines[i]);
const col = {};
for (const [key, re] of Object.entries(COLUMNS)) {
const idx = header.findIndex((h) => re.test(h));
if (idx < 0)
throw new PricingFormatError(`column for "${key}" not found in the price table — the page format changed`);
col[key] = idx;
}
i += 2; // header + separator row
const models = {};
const skipped = [];
for (; i < lines.length && lines[i].trim().startsWith("|"); i++) {
const row = cells(lines[i]);
const name = stripMarkup(row[col.model] ?? "").replace(/\s*\(.*\)\s*$/, "");
const id = modelIdFor(name);
if (!id) {
skipped.push(`"${name}": not a recognizable model name`);
continue;
}
if (models[id]) {
skipped.push(`"${name}": listed twice, first row kept`);
continue;
}
const price = {};
let missing = null;
for (const f of ["input", "cache_write_5m", "cache_write_1h", "cache_read", "output"]) {
const v = parsePrice(row[col[f]] ?? "");
if (v === null) {
missing = `${f} "${row[col[f]] ?? ""}" is not "$X / MTok"`;
break;
}
price[f] = v;
}
const bad = missing ?? plausiblePrice(price);
if (bad) {
skipped.push(`"${name}": ${bad}`);
continue;
}
models[id] = { name, ...price };
}
if (Object.keys(models).length < MIN_MODELS) {
const why = skipped.length ? `; skipped: ${skipped.join("; ")}` : "";
throw new PricingFormatError(`only ${Object.keys(models).length} model(s) read from the price table${why} — the page format changed`);
}
return { models, skipped };
}
export function fetchText(url, opts = {}) {
const { timeoutMs = 15_000, maxBytes = 2_000_000, redirects = 3 } = opts;
return new Promise((resolve, reject) => {
const { client, agent } = upstreamTransport(url);
const req = client.get(url, { agent, headers: { accept: "text/markdown, text/plain;q=0.9", "user-agent": "vantage (pricing update)" } }, (res) => {
const status = res.statusCode ?? 0;
const location = res.headers.location;
if (status >= 300 && status < 400 && location) {
res.resume();
if (redirects <= 0)
return reject(new Error(`too many redirects fetching ${url}`));
const next = new URL(location, url).href;
return void fetchText(next, { timeoutMs, maxBytes, redirects: redirects - 1 }).then(resolve, reject);
}
if (status !== 200) {
res.resume();
return reject(new Error(`${url} answered ${status}`));
}
const chunks = [];
let size = 0;
res.on("data", (c) => {
size += c.length;
if (size > maxBytes) {
req.destroy(new Error(`${url} is larger than ${maxBytes} bytes`));
return;
}
chunks.push(c);
});
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
res.on("error", reject);
});
req.setTimeout(timeoutMs, () => req.destroy(new Error(`no answer from ${url} within ${timeoutMs / 1000}s`)));
req.on("error", reject);
});
}
export async function fetchPriceTable(url = pricingSourceUrl(), now = new Date()) {
const md = await fetchText(url);
const { models, skipped } = parsePricingMarkdown(md);
return { source: url, fetchedAt: now.toISOString(), models, skipped };
}
// Any provider's page, read by its own parser. `at` decides which of
// Google's dated prices are current.
export function parseProviderPage(provider, md, at = new Date()) {
if (provider === "openai")
return parseOpenAiPricing(md);
if (provider === "google")
return parseGooglePricing(md, at);
return parsePricingMarkdown(md);
}
export async function fetchProviderTable(provider, now = new Date(), url = pricingSourceUrl(provider)) {
const md = await fetchText(url);
const { models, skipped } = parseProviderPage(provider, md, now);
return { provider, source: url, fetchedAt: now.toISOString(), models, skipped };
}
// ---------------------------------------------------------------------------
// OpenAI: one table per processing tier (Standard, Batch, Flex, Fast mode)
// and model group. Only Standard prices are read; the columns are named, in
// the flagship tables, "Short context input", "… cached input", "… cache
// writes", "… output" and the same for "Long context". A model whose long
// context starts at a size says so in its name: "gpt-5.5 (<272K context
// length)". Tables of other kinds (audio, images, fine-tuning, tools) have
// other columns and are passed over.
const OPENAI_MIN_MODELS = 10;
const TIERS = new Set(["Standard", "Batch", "Flex", "Fast mode", "Fast", "Priority"]);
function dollars(cell) {
const m = /^\$(\d+(?:\.\d+)?)$/.exec(stripMarkup(cell).replace(/,/g, ""));
return m ? Number(m[1]) : null;
}
export function parseOpenAiPricing(md) {
const lines = md.split(/\r?\n/);
const models = {};
const skipped = [];
let tier = "Standard";
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (TIERS.has(line))
tier = line;
// Each model group starts with its unit; its first table is Standard.
if (/^Prices per 1M tokens\.?$/i.test(line))
tier = "Standard";
if (!line.startsWith("|") || !lines[i + 1]?.trim().startsWith("| ---"))
continue;
const header = cells(line).map((h) => h.toLowerCase());
const find = (re) => header.findIndex((h) => re.test(h));
const col = {
model: find(/^model$/),
input: find(/^(?:short context )?input$/),
cached: find(/^(?:short context )?cached input$/),
writes: find(/^(?:short context )?cache writes$/),
output: find(/^(?:short context )?output$/),
longInput: find(/^long context input$/),
longCached: find(/^long context cached input$/),
longWrites: find(/^long context cache writes$/),
longOutput: find(/^long context output$/),
};
let j = i + 2;
const rows = [];
for (; j < lines.length && lines[j].trim().startsWith("|"); j++)
rows.push(cells(lines[j]));
i = j - 1;
// Only token prices by model: no modality or training splits.
if (tier !== "Standard" || col.model < 0 || col.input < 0 || col.output < 0 || header.some((h) => /modality|training|per minute/.test(h)))
continue;
for (const row of rows) {
const raw = stripMarkup(row[col.model] ?? "");
const id = raw.replace(/\s*\(.*\)\s*$/, "").trim();
if (!/^[a-z0-9][a-z0-9.\-]*$/i.test(id))
continue;
if (models[id])
continue; // the flagship table comes first
const input = dollars(row[col.input] ?? "");
const output = dollars(row[col.output] ?? "");
if (input === null || output === null) {
skipped.push(`"${raw}": no input and output price`);
continue;
}
const cached = col.cached >= 0 ? dollars(row[col.cached] ?? "") : null;
const writes = col.writes >= 0 ? dollars(row[col.writes] ?? "") : null;
const price = { input, cache_write_5m: writes ?? input, cache_write_1h: writes ?? input, cache_read: cached ?? input, output };
// Long-context prices count only where the page names the limit.
const limit = /<\s*(\d+(?:\.\d+)?)\s*K context length/i.exec(raw);
const longInput = col.longInput >= 0 ? dollars(row[col.longInput] ?? "") : null;
const longOutput = col.longOutput >= 0 ? dollars(row[col.longOutput] ?? "") : null;
if (limit && longInput !== null && longOutput !== null) {
const lc = col.longCached >= 0 ? dollars(row[col.longCached] ?? "") : null;
const lw = col.longWrites >= 0 ? dollars(row[col.longWrites] ?? "") : null;
price.long = { above: Number(limit[1]) * 1000, input: longInput, cache_write_5m: lw ?? longInput, cache_write_1h: lw ?? longInput, cache_read: lc ?? longInput, output: longOutput };
}
const bad = plausibleEntry(price, false);
if (bad) {
skipped.push(`"${raw}": ${bad}`);
continue;
}
models[id] = { name: id, ...price };
}
}
if (Object.keys(models).length < OPENAI_MIN_MODELS) {
throw new PricingFormatError(`only ${Object.keys(models).length} model(s) read from OpenAI's price tables — the page format changed`);
}
return { models, skipped };
}
// ---------------------------------------------------------------------------
// Google: one section per model ("## Gemini 2.5 Pro", with its IDs as
// links: [`gemini-2.5-pro`](…)), and in it a table per tier; "### Standard" is
// read, its "Paid Tier" column. Only models billed for text output ("Output
// price (including thinking tokens)") — the ones coding agents call. A cell
// can hold more than one price:
//
// $1.25, prompts <= 200k tokens $2.50, prompts > 200k tokens (long context)
// $0.75 through December 31, 2026. $1.50 starting January 1, 2027. (announced)
// $0.30 (text / image / video) $1.00 (audio) (by modality: text is read)
// … $1.00 / 1,000,000 tokens per hour (storage price) (not per token: left out)
const GOOGLE_MIN_MODELS = 3;
function unescape(s) {
return s.replace(/\\([<>*_\[\]()])/g, "$1").replace(/\^\\?\*+\^/g, "").replace(/\^[^^]*\^/g, "");
}
// The prices in one Google cell, as they apply at `at`.
export function parseGoogleCell(cell, at = new Date()) {
const text = unescape(stripMarkup(cell));
const parts = [];
const re = /\$(\d+(?:\.\d+)?)/g;
const found = [...text.matchAll(re)];
found.forEach((m, k) => {
const end = k + 1 < found.length ? found[k + 1].index : text.length;
parts.push({ value: Number(m[1]), rest: text.slice(m.index + m[0].length, end) });
});
// Only per-token text prices.
const priced = parts.filter((p) => !/storage|per hour|\/\s*min|per (?:image|second|minute|request)|1,000 requests/i.test(p.rest) && (!/\((?![^)]*text)[^)]*\)/i.test(p.rest) || /\(\s*(?:text|input caching)/i.test(p.rest)));
if (priced.length === 0)
return null;
const low = priced.find((p) => /prompts\s*<=\s*\d+k/i.test(p.rest));
const high = priced.find((p) => /prompts\s*>\s*\d+k/i.test(p.rest));
const through = priced.find((p) => /through\s+[A-Z][a-z]+ \d{1,2}, \d{4}/.test(p.rest));
const starting = priced.find((p) => /starting\s+[A-Z][a-z]+ \d{1,2}, \d{4}/.test(p.rest));
if (low && high) {
const k = Number(/prompts\s*<=\s*(\d+)k/i.exec(low.rest)[1]);
return { now: low.value, long: { above: k * 1000, value: high.value } };
}
if (through && starting) {
const end = Date.parse(/through\s+([A-Z][a-z]+ \d{1,2}, \d{4})/.exec(through.rest)[1]);
const from = Date.parse(/starting\s+([A-Z][a-z]+ \d{1,2}, \d{4})/.exec(starting.rest)[1]);
if (Number.isNaN(end) || Number.isNaN(from))
return null;
// Dates are the provider's calendar days: the new price applies from
// the start of that day (UTC).
if (at.getTime() >= from)
return { now: starting.value };
return { now: through.value, next: { from: new Date(from).toISOString().slice(0, 10), value: starting.value } };
}
return { now: priced[0].value };
}
export function parseGooglePricing(md, at = new Date()) {
const models = {};
const skipped = [];
const sections = md.split(/\n## /).slice(1);
for (const section of sections) {
const title = section.split("\n")[0].trim();
const head = section.split(/\n### /)[0];
const ids = [...head.matchAll(/\[`([a-z0-9.\-]+)`\]\(/g)].map((m) => m[1]);
if (ids.length === 0)
continue;
const std = /\n### Standard\n([\s\S]*?)(?=\n### |$)/.exec(section);
if (!std)
continue;
const lines = std[1].split("\n").filter((l) => l.trim().startsWith("|"));
if (lines.length < 3)
continue;
const header = cells(lines[0]);
const paid = header.findIndex((h) => /paid tier/i.test(h));
if (paid < 0)
continue;
const row = (re) => {
const r = lines.map(cells).find((c) => re.test(unescape(stripMarkup(c[0] ?? ""))));
return r ? r[paid] ?? null : null;
};
const outputCell = row(/^Output price \(including thinking/i);
if (!outputCell)
continue; // not a text model
const inputCell = row(/^Input price/i);
const cacheCell = row(/^Context caching price/i);
const input = inputCell ? parseGoogleCell(inputCell, at) : null;
const output = parseGoogleCell(outputCell, at);
const cache = cacheCell ? parseGoogleCell(cacheCell, at) : null;
if (!input || !output) {
skipped.push(`"${title}": prices not readable`);
continue;
}
const base = (i, o, c) => ({ input: i, cache_write_5m: i, cache_write_1h: i, cache_read: c, output: o });
const price = base(input.now, output.now, cache?.now ?? input.now);
if (input.long && output.long) {
price.long = { above: input.long.above, ...base(input.long.value, output.long.value, cache?.long?.value ?? input.long.value) };
}
if (input.next || output.next) {
const from = (input.next ?? output.next).from;
price.next = { from, ...base(input.next?.value ?? input.now, output.next?.value ?? output.now, cache?.next?.value ?? cache?.now ?? input.next?.value ?? input.now) };
}
const bad = plausibleEntry(price, false);
if (bad) {
skipped.push(`"${title}": ${bad}`);
continue;
}
for (const id of ids)
if (!models[id])
models[id] = { name: title, ...price };
}
if (Object.keys(models).length < GOOGLE_MIN_MODELS) {
throw new PricingFormatError(`only ${Object.keys(models).length} model(s) read from Google's price page — the page format changed`);
}
return { models, skipped };
}
const FIELDS = ["input", "cache_write_5m", "cache_write_1h", "cache_read", "output"];
const extras = (p) => JSON.stringify([p.long ?? null, p.next ?? null]);
export function diffPrices(before, after) {
const added = [];
const changed = [];
for (const [id, a] of Object.entries(after)) {
const b = before[id];
if (!b)
added.push(id);
else if (FIELDS.some((f) => b[f] !== a[f]) || extras(b) !== extras(a))
changed.push({ id, before: b, after: a });
}
const unlisted = Object.keys(before).filter((id) => !(id in after));
return { added, changed, unlisted };
}
export function hasDrift(d) {
return d.added.length > 0 || d.changed.length > 0;
}
// Price changes too large to take over without a person looking: a price
// that moved by more than `maxFactor` either way is far more likely a
// misread page than a real price change.
// Returns one line per suspicious price, empty when all is plausible.
export function implausibleChanges(d, maxFactor = 10) {
const out = [];
for (const c of d.changed) {
for (const f of FIELDS) {
const ratio = c.after[f] / c.before[f];
if (ratio > maxFactor || ratio < 1 / maxFactor)
out.push(`${c.id}: ${f} $${c.before[f]} → $${c.after[f]}`);
}
}
return out;
}
// The bundled list after an update: the fetched prices, plus every model
// the page no longer lists — a retired model still prices old sessions.
export function nextSnapshot(current, fetched) {
return { ...fetched, models: { ...current.models, ...fetched.models } };
}
export function formatChange(c) {
const parts = FIELDS.filter((f) => c.before[f] !== c.after[f]).map((f) => `${f} $${c.before[f]} → $${c.after[f]}`);
if (extras(c.before) !== extras(c.after))
parts.push("long-context or announced prices");
return `${c.id}: ${parts.join(", ")}`;
}