Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions scripts/verify-prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CatalogueModel[] | string> {
try {
Expand All @@ -534,8 +539,8 @@ async function catalogue(host: string): Promise<CatalogueModel[] | string> {
// 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<CatalogueModel[] | string> {
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 22 additions & 7 deletions src/tools/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
125 changes: 124 additions & 1 deletion src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,77 @@ export const CHAT_PRICE_PER_MTOKEN: Record<string, { input: number; output: numb
*/
export const DEFAULT_CHAT_PRICE = { input: 5, output: 30 } as const;

/**
* One long-context price step, per 1M tokens: once the prompt crosses
* `threshold` input tokens, the WHOLE request (input and output) bills at
* these rates. `inclusive` says whether a prompt of exactly `threshold` tokens
* is already in the step — OpenAI and Google publish "> 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<string, readonly LongContextStep[]> = {
// 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.
*
Expand Down Expand Up @@ -441,7 +512,12 @@ export const GATEWAY_CHARS_PER_TOKEN_OBSERVED = 2.08;
* mapping is unambiguous.
*/
const BARE_TO_PREFIXED: Record<string, string> = 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]),
);
Expand Down Expand Up @@ -469,6 +545,53 @@ export const TIER_WORST_PRICE: Record<RoutingMode, { input: number; output: numb
}),
) as Record<RoutingMode, { input: number; output: number }>;

/**
* 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.
*
Expand Down
Loading
Loading