diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index 0817cf2..beb28c8 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -52,7 +52,7 @@ import { estimateChatCost, promptCharSize } from "../src/tools/chat.js"; import { estimateVideoCost } from "../src/tools/video.js"; import { MARKETS_PRICE_USD } from "../src/tools/markets.js"; import { withTxFee } from "../src/utils/tx-fee.js"; -import { CHAT_PRICE_PER_MTOKEN, DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS, MODEL_TIERS } from "../src/utils/constants.js"; +import { CHAT_PRICE_PER_MTOKEN, DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS, MODEL_TIERS, chatRateAt } from "../src/utils/constants.js"; import { verdict } from "./verify-prices-verdict.js"; // TWO gateways, and they do not agree. Base and Solana are separate deployments @@ -516,7 +516,12 @@ if (solMissing) { // Listed-but-unknown $0 models and unlisted free[] entries are reported, not // failed: the first only over-reserves, and absence from the catalogue is a // listing decision, not a death certificate (see the doctrine in constants.ts). -type CatalogueModel = { id: string; available?: boolean; pricing?: { input?: unknown; output?: unknown } }; +type LiveLongContextStep = { threshold?: unknown; inclusive?: unknown; input?: unknown; output?: unknown }; +type CatalogueModel = { + id: string; + available?: boolean; + pricing?: { input?: unknown; output?: unknown; long_context?: LiveLongContextStep[] }; +}; async function catalogue(host: string): Promise { try { @@ -534,8 +539,8 @@ async function catalogue(host: string): Promise { // applied — chatMarginPercent is 0 today, but the field is the one that would // move if that changed); `inputPrice` is the pre-margin figure and is NOT what // settles. Long-context ladders (a model repricing above N input tokens) are -// on the sheet too and are deliberately not compared: no estimator in this -// repo models them, so they are a separate finding, not a row here. +// on the sheet too but are not compared here; the Base and Solana catalogues' +// `pricing.long_context` steps are, against LONG_CONTEXT_PRICE_PER_MTOKEN. type SheetModel = { id: string; available?: boolean; billingMode?: string; inputPricePerMillion?: unknown; outputPricePerMillion?: unknown }; async function accountSheet(): Promise { @@ -604,6 +609,25 @@ for (const [name, read] of CATALOGUES) { ); continue; } + // Long-context steps: at the first token count each live step covers, the + // reserve (base raised to OUR step, if any) must cover the live step rate. + // A step we lack, price low, or start later than the gateway is the same + // under-reserve as a missing row, reached only by a huge prompt. + if (!isFree) { + for (const step of m.pricing?.long_context ?? []) { + const { threshold, inclusive, input: stepIn, output: stepOut } = step; + if (typeof threshold !== "number" || typeof stepIn !== "number" || typeof stepOut !== "number") continue; + const tokens = inclusive === true ? threshold : threshold + 1; + const at = chatRateAt(m.id, reserve, tokens); + if (stepIn > at.input || stepOut > at.output) { + gaps++; + catalogueGaps.push( + `${name}: ${m.id} is $${stepIn}/$${stepOut} live from ${inclusive === true ? ">=" : ">"}${threshold} prompt tokens, ` + + `but reserves $${at.input}/$${at.output} there — add or fix its LONG_CONTEXT_PRICE_PER_MTOKEN steps`, + ); + } + } + } if (row && (input < row.input || output < row.output)) { if (name === "Base" && m.id.startsWith("anthropic/")) { // Native Anthropic is Base-only and books this row as the ledger. diff --git a/src/tools/chat.ts b/src/tools/chat.ts index a7d5eb3..6b72e7d 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -23,9 +23,10 @@ import { DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS, canonicalChatModel, - TIER_WORST_PRICE, GATEWAY_CHARS_PER_TOKEN, GATEWAY_CHARS_PER_TOKEN_OBSERVED, + chatRateAt, + tierWorstPriceAt, type RoutingMode, } from "../utils/constants.js"; import { reserveBudget, recordActualSpend } from "../utils/budget.js"; @@ -176,12 +177,23 @@ export function estimateChatCost( // table (src/tools/modal.ts) and it was reintroduced here the moment these // tables were added; measured before the guard: model:"constructor", // "toString", "__proto__", "hasOwnProperty" and "valueOf" all reserved $0. + // + // Long-context steps: past a model's threshold the gateway quotes the WHOLE + // request at the step's rates (2x input / 1.5x output on GPT-6 and GPT-5.4+ + // above 272K, 2x on Grok from 200K, 2x/1.5x on Gemini Pro above 200K). The + // step is picked from the same 2-chars/token count the input term uses, so + // this stays a pure function of prompt size — and it rounds toward the step, + // the safe side. See LONG_CONTEXT_PRICE_PER_MTOKEN. const effectiveMode = (mode ?? "balanced") as RoutingMode; + const inTokens = Math.ceil((promptChars ?? 0) / GATEWAY_CHARS_PER_TOKEN); const rate = canonical - ? (Object.hasOwn(CHAT_PRICE_PER_MTOKEN, canonical) ? CHAT_PRICE_PER_MTOKEN[canonical] : DEFAULT_CHAT_PRICE) - : (Object.hasOwn(TIER_WORST_PRICE, effectiveMode) ? TIER_WORST_PRICE[effectiveMode] : DEFAULT_CHAT_PRICE); + ? chatRateAt( + canonical, + Object.hasOwn(CHAT_PRICE_PER_MTOKEN, canonical) ? CHAT_PRICE_PER_MTOKEN[canonical] : DEFAULT_CHAT_PRICE, + inTokens, + ) + : (Object.hasOwn(MODEL_TIERS, effectiveMode) ? tierWorstPriceAt(effectiveMode, inTokens) : DEFAULT_CHAT_PRICE); - const inTokens = Math.ceil((promptChars ?? 0) / GATEWAY_CHARS_PER_TOKEN); // Rounded to micro-dollars because the raw float drifts — (1024/1e6)*20 is // 0.020479999999999998, which then surfaces verbatim in budget messages. const micro = (usd: number) => Math.round(usd * 1e6) / 1e6; @@ -227,10 +239,13 @@ export function accountLedgerUsd( const requested = canonicalChatModel(requestedModel); if (FREE_CHAT_MODELS.has(requested)) return 0; const served = servedModel ? canonicalChatModel(servedModel) : null; - const rate = served && Object.hasOwn(CHAT_PRICE_PER_MTOKEN, served) ? CHAT_PRICE_PER_MTOKEN[served] - : Object.hasOwn(CHAT_PRICE_PER_MTOKEN, requested) ? CHAT_PRICE_PER_MTOKEN[requested] - : DEFAULT_CHAT_PRICE; + const priced = served && Object.hasOwn(CHAT_PRICE_PER_MTOKEN, served) ? served + : Object.hasOwn(CHAT_PRICE_PER_MTOKEN, requested) ? requested + : null; const inTokens = usage?.promptTokens ?? Math.ceil(promptChars / GATEWAY_CHARS_PER_TOKEN_OBSERVED); + // The long-context step of whichever model the rate came from (the requested + // one when neither has a row), at the prompt's own token count. + const rate = chatRateAt(priced ?? requested, priced ? CHAT_PRICE_PER_MTOKEN[priced] : DEFAULT_CHAT_PRICE, inTokens); const outTokens = usage?.completionTokens ?? maxTokens; const usd = (inTokens / 1_000_000) * rate.input + (outTokens / 1_000_000) * rate.output; // Whole micro-dollars, rounded up as the gateway bills; the epsilon keeps a diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9088b5c..3143d7c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -386,6 +386,77 @@ export const CHAT_PRICE_PER_MTOKEN: Record threshold", xAI and + * Qwen publish ">= threshold". + */ +export interface LongContextStep { + readonly threshold: number; + readonly inclusive: boolean; + readonly input: number; + readonly output: number; +} + +/** + * Long-context steps, copied from `pricing.long_context` in GET /v1/models + * (blockrun.ai and sol.blockrun.ai agree; the Qwen steps are on Base only and + * are kept because a reserve has to cover the dearer rail). Read 2026-10-02. + * + * Without this the gate reserved base rates for every prompt, and the gateway + * quotes the step: a 300K-token prompt to gpt-6-astra reserved $10/M input + * against a $20/M quote, so a huge prompt could pass a cap it would then blow. + * + * Rates are ABSOLUTE, as the catalogue publishes them, and chatRateAt() never + * lets a step LOWER the base rate it would otherwise reserve — a model with no + * CHAT_PRICE_PER_MTOKEN row reserves the $5/$30 default, which is above + * gpt-5.4's $22.50/M long-context output, and that must stay the floor. + * + * Steps are ascending; the highest one the prompt reaches prices the request + * (qwen3.7-flash steps twice). Same rule as the gateway's quote + * (blockrun src/lib/chat-quote.ts, longContextLadder). + */ +const OPENAI_272K = (input: number, output: number): readonly LongContextStep[] => + [{ threshold: 272_000, inclusive: false, input, output }]; +const GOOGLE_200K = (input: number, output: number): readonly LongContextStep[] => + [{ threshold: 200_000, inclusive: false, input, output }]; +const XAI_200K = (input: number, output: number): readonly LongContextStep[] => + [{ threshold: 200_000, inclusive: true, input, output }]; + +export const LONG_CONTEXT_PRICE_PER_MTOKEN: Record = { + // OpenAI GPT-6 and GPT-5.4+: 2x input / 1.5x output above 272K. + "openai/gpt-6-astra": OPENAI_272K(20, 75), + "openai/gpt-6-sol": OPENAI_272K(4, 15), + "openai/gpt-6-luna": OPENAI_272K(0.2, 0.75), + "openai/gpt-5.6-sol": OPENAI_272K(8, 30), + "openai/gpt-5.6-terra": OPENAI_272K(4, 18), + "openai/gpt-5.6-luna": OPENAI_272K(0.4, 1.8), + "openai/gpt-5.6-sol-pro": OPENAI_272K(8, 30), + "openai/gpt-5.6-terra-pro": OPENAI_272K(4, 18), + "openai/gpt-5.6-luna-pro": OPENAI_272K(0.4, 1.8), + "openai/gpt-5.5": OPENAI_272K(10, 45), + "openai/gpt-5.5-pro": OPENAI_272K(60, 270), + "openai/gpt-5.4": OPENAI_272K(5, 22.5), + "openai/gpt-5.4-pro": OPENAI_272K(60, 270), + // Google Gemini Pro: 2x input / 1.5x output above 200K. + "google/gemini-3.1-pro": GOOGLE_200K(4, 18), + "google/gemini-2.5-pro": GOOGLE_200K(2.5, 15), + // xAI Grok: 2x both, from 200K inclusive. + "xai/grok-4.7": XAI_200K(4, 12), + "xai/grok-4.6": XAI_200K(4, 12), + "xai/grok-4.5": XAI_200K(4, 12), + "xai/grok-4.3": XAI_200K(2.5, 5), + "xai/grok-build-0.1": XAI_200K(2, 4), + // Qwen (Base): inclusive steps, flash steps twice. + "qwen/qwen3.7-plus": [{ threshold: 256_000, inclusive: true, input: 0.96, output: 3.84 }], + "qwen/qwen3.7-flash": [ + { threshold: 32_000, inclusive: true, input: 0.1, output: 0.4 }, + { threshold: 256_000, inclusive: true, input: 0.2, output: 0.8 }, + ], +}; + /** * Characters per input token, as the GATEWAY counts them for a quote. * @@ -441,7 +512,12 @@ export const GATEWAY_CHARS_PER_TOKEN_OBSERVED = 2.08; * mapping is unambiguous. */ const BARE_TO_PREFIXED: Record = Object.fromEntries( - [...Object.keys(CHAT_PRICE_PER_MTOKEN), ...Object.values(MODEL_TIERS).flat(), ...FREE_CHAT_MODELS] + [ + ...Object.keys(CHAT_PRICE_PER_MTOKEN), + ...Object.keys(LONG_CONTEXT_PRICE_PER_MTOKEN), + ...Object.values(MODEL_TIERS).flat(), + ...FREE_CHAT_MODELS, + ] .filter((id: string) => id.includes("/")) .map((id: string) => [id.slice(id.indexOf("/") + 1), id]), ); @@ -469,6 +545,53 @@ export const TIER_WORST_PRICE: Record; +/** + * The long-context step a prompt of `inputTokens` reaches on `model`, or null + * when it reaches none (or the model has no steps). `model` must already be + * canonical. hasOwn-guarded: `model` is caller-controlled, and an inherited + * Object.prototype key must not resolve to a function here. + */ +export function longContextStep(model: string, inputTokens: number): LongContextStep | null { + if (!Object.hasOwn(LONG_CONTEXT_PRICE_PER_MTOKEN, model)) return null; + let reached: LongContextStep | null = null; + for (const step of LONG_CONTEXT_PRICE_PER_MTOKEN[model]) { + if (step.inclusive ? inputTokens >= step.threshold : inputTokens > step.threshold) reached = step; + } + return reached; +} + +/** + * `base` raised to the long-context step `inputTokens` reaches on `model`. + * Component-wise max: a step can only raise what would be reserved or booked, + * never lower it (see LONG_CONTEXT_PRICE_PER_MTOKEN). Pure in prompt size. + */ +export function chatRateAt( + model: string, + base: { input: number; output: number }, + inputTokens: number, +): { input: number; output: number } { + const step = longContextStep(model, inputTokens); + if (!step) return base; + return { input: Math.max(base.input, step.input), output: Math.max(base.output, step.output) }; +} + +/** + * TIER_WORST_PRICE at a given prompt size: the most expensive rate any member + * of the tier can settle at once long-context steps apply. Equal to + * TIER_WORST_PRICE[mode] below every member's threshold. + */ +export function tierWorstPriceAt(mode: RoutingMode, inputTokens: number): { input: number; output: number } { + const rates = MODEL_TIERS[mode].map((id: string) => { + if (FREE_CHAT_MODELS.has(id) && !Object.hasOwn(CHAT_PRICE_PER_MTOKEN, id)) return { input: 0, output: 0 }; + const base = Object.hasOwn(CHAT_PRICE_PER_MTOKEN, id) ? CHAT_PRICE_PER_MTOKEN[id] : DEFAULT_CHAT_PRICE; + return chatRateAt(id, base, inputTokens); + }); + return { + input: Math.max(...rates.map((r) => r.input)), + output: Math.max(...rates.map((r) => r.output)), + }; +} + /** * Per-model and whole-loop deadlines for the mode:"free" routing fallback. * diff --git a/test/long-context.test.ts b/test/long-context.test.ts new file mode 100644 index 0000000..35268ac --- /dev/null +++ b/test/long-context.test.ts @@ -0,0 +1,139 @@ +// Long-context price steps in the chat cost estimator. +// Run with: npm test +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { estimateChatCost, accountLedgerUsd } from "../src/tools/chat.js"; +import { + CHAT_PRICE_PER_MTOKEN, + DEFAULT_CHAT_PRICE, + GATEWAY_CHARS_PER_TOKEN, + LONG_CONTEXT_PRICE_PER_MTOKEN, + MODEL_TIERS, + TIER_WORST_PRICE, + chatRateAt, + longContextStep, + tierWorstPriceAt, +} from "../src/utils/constants.js"; +import { withTxFee } from "../src/utils/tx-fee.js"; + +const MAX_TOKENS = 1024; + +/** promptChars that the estimator counts as exactly `tokens` input tokens. */ +const charsFor = (tokens: number) => tokens * GATEWAY_CHARS_PER_TOKEN; + +/** The reserve estimateChatCost should produce at a given rate. */ +function reserveAt(rate: { input: number; output: number }, tokens: number): number { + const micro = (usd: number) => Math.round(usd * 1e6) / 1e6; + return withTxFee( + Math.max(micro((tokens / 1e6) * rate.input) + micro((MAX_TOKENS / 1e6) * rate.output), 0.001), + ); +} + +test("exclusive threshold: exactly 272K tokens on GPT-6 stays at the base rate", () => { + const base = CHAT_PRICE_PER_MTOKEN["openai/gpt-6-astra"]; + const at = estimateChatCost(MAX_TOKENS, undefined, "openai/gpt-6-astra", undefined, charsFor(272_000)); + assert.equal(at, reserveAt(base, 272_000)); +}); + +test("exclusive threshold: one token past 272K reprices the WHOLE request at 2x in / 1.5x out", () => { + const base = CHAT_PRICE_PER_MTOKEN["openai/gpt-6-astra"]; + const past = estimateChatCost(MAX_TOKENS, undefined, "openai/gpt-6-astra", undefined, charsFor(272_001)); + assert.equal(past, reserveAt({ input: base.input * 2, output: base.output * 1.5 }, 272_001)); + assert.ok(past > estimateChatCost(MAX_TOKENS, undefined, "openai/gpt-6-astra", undefined, charsFor(272_000)) * 1.9); +}); + +test("inclusive threshold: Grok is in the 2x step at exactly 200K, not at 199,999", () => { + const base = CHAT_PRICE_PER_MTOKEN["xai/grok-4.7"]; + const below = estimateChatCost(MAX_TOKENS, undefined, "xai/grok-4.7", undefined, charsFor(199_999)); + const at = estimateChatCost(MAX_TOKENS, undefined, "xai/grok-4.7", undefined, charsFor(200_000)); + assert.equal(below, reserveAt(base, 199_999)); + assert.equal(at, reserveAt({ input: base.input * 2, output: base.output * 2 }, 200_000)); +}); + +test("Gemini Pro steps above 200K (exclusive) at 2x in / 1.5x out", () => { + const base = CHAT_PRICE_PER_MTOKEN["google/gemini-3.1-pro"]; + assert.equal( + estimateChatCost(MAX_TOKENS, undefined, "google/gemini-3.1-pro", undefined, charsFor(200_000)), + reserveAt(base, 200_000), + ); + assert.equal( + estimateChatCost(MAX_TOKENS, undefined, "google/gemini-3.1-pro", undefined, charsFor(200_001)), + reserveAt({ input: base.input * 2, output: base.output * 1.5 }, 200_001), + ); +}); + +test("the steps match the live multipliers for every tiered row that has a base row", () => { + for (const [id, steps] of Object.entries(LONG_CONTEXT_PRICE_PER_MTOKEN)) { + if (!Object.hasOwn(CHAT_PRICE_PER_MTOKEN, id) || id.startsWith("qwen/")) continue; + const base = CHAT_PRICE_PER_MTOKEN[id]; + const [step] = steps; + const [inMul, outMul] = id.startsWith("xai/") ? [2, 2] : [2, 1.5]; + assert.ok(Math.abs(step.input - base.input * inMul) < 1e-9, `${id} input step ${step.input}`); + assert.ok(Math.abs(step.output - base.output * outMul) < 1e-9, `${id} output step ${step.output}`); + assert.equal(step.inclusive, id.startsWith("xai/"), `${id} inclusive flag`); + } +}); + +test("a multi-step ladder prices at the highest step reached (qwen3.7-flash: 32K, 256K)", () => { + assert.equal(longContextStep("qwen/qwen3.7-flash", 31_999), null); + assert.equal(longContextStep("qwen/qwen3.7-flash", 32_000)?.input, 0.1); + assert.equal(longContextStep("qwen/qwen3.7-flash", 255_999)?.input, 0.1); + assert.equal(longContextStep("qwen/qwen3.7-flash", 256_000)?.input, 0.2); +}); + +test("a step never lowers the reserve below the base it would otherwise use", () => { + // gpt-5.4 has no CHAT_PRICE_PER_MTOKEN row, so it reserves the $5/$30 default; + // its published >272K output rate ($22.50) is below that and must not win. + const rate = chatRateAt("openai/gpt-5.4", DEFAULT_CHAT_PRICE, 300_000); + assert.deepEqual(rate, { input: DEFAULT_CHAT_PRICE.input, output: DEFAULT_CHAT_PRICE.output }); + assert.ok( + estimateChatCost(MAX_TOKENS, undefined, "gpt-5.4", undefined, charsFor(300_000)) >= + estimateChatCost(MAX_TOKENS, undefined, "someone/unknown", undefined, charsFor(300_000)), + ); +}); + +test("bare ids pick up the step too", () => { + assert.equal( + estimateChatCost(MAX_TOKENS, undefined, "gpt-6-astra", undefined, charsFor(300_000)), + estimateChatCost(MAX_TOKENS, undefined, "openai/gpt-6-astra", undefined, charsFor(300_000)), + ); +}); + +test("routing modes reserve the worst member's long-context step for a huge prompt", () => { + for (const mode of Object.keys(MODEL_TIERS) as (keyof typeof MODEL_TIERS)[]) { + assert.deepEqual(tierWorstPriceAt(mode, 1_000), TIER_WORST_PRICE[mode], `${mode} below every threshold`); + } + // balanced holds gpt-5.5 ($5/$30 -> $10/$45 past 272K). + const huge = tierWorstPriceAt("balanced", 300_000); + assert.deepEqual(huge, { input: 10, output: 45 }); + assert.ok( + estimateChatCost(MAX_TOKENS, "balanced", undefined, undefined, charsFor(300_000)) > + reserveAt(TIER_WORST_PRICE.balanced, 300_000), + ); + assert.equal(estimateChatCost(MAX_TOKENS, "free", undefined, undefined, charsFor(300_000)), 0); +}); + +test("models without steps are unchanged at any prompt size", () => { + const base = CHAT_PRICE_PER_MTOKEN["anthropic/claude-opus-5"]; + assert.equal( + estimateChatCost(MAX_TOKENS, undefined, "anthropic/claude-opus-5", undefined, charsFor(900_000)), + reserveAt(base, 900_000), + ); +}); + +test("prototype keys do not resolve to a step", () => { + for (const key of ["constructor", "toString", "__proto__", "hasOwnProperty", "valueOf"]) { + assert.equal(longContextStep(key, 10_000_000), null, key); + } +}); + +test("account-rail ledger books the step from the reported prompt tokens", () => { + const usage = (promptTokens: number) => ({ promptTokens, completionTokens: 1_000 }); + const base = CHAT_PRICE_PER_MTOKEN["openai/gpt-5.5"]; + const below = accountLedgerUsd("openai/gpt-5.5", null, 0, 1_000, usage(272_000)); + const above = accountLedgerUsd("openai/gpt-5.5", null, 0, 1_000, usage(272_001)); + const usd = (r: { input: number; output: number }, t: number) => + Math.ceil(((t / 1e6) * r.input + (1_000 / 1e6) * r.output) * 1e6 - 1e-6) / 1e6; + assert.equal(below, usd(base, 272_000)); + assert.equal(above, usd({ input: 10, output: 45 }, 272_001)); +});