Skip to content

Commit c72dc99

Browse files
committed
fix(usage): price long-context requests at the published long rate (#908)
Several vendors reprice the entire request once the prompt crosses a token threshold, and a flat Cost4 could not express it — so every request billed at the short rate, including the long ones, which are the expensive ones. The threshold reads raw usage.inputTokens, not normalized billable input: a 280k prompt with a 200k cache read has 80k billable input and still crosses OpenAI's 272k boundary. Deciding after normalization would have under-billed exactly the cache-heavy long requests. Long context and Fast are mutually exclusive, not composable. OpenAI does not serve long context in Fast mode, so exclusivity keys on the response-confirmed tier: a >272k request merely tagged priority was necessarily downgraded and bills long. That needed tier provenance at all four estimator call sites instead of the collapsed scalar. Also adds base prices for the three -pro virtual aliases, which resolved to null and rendered no cost estimate at all. Fixes #908
1 parent 151da16 commit c72dc99

5 files changed

Lines changed: 268 additions & 26 deletions

File tree

src/server/management/shared.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustom
5151
import type { DesktopProfileModel } from "../../claude/desktop-profile";
5252
import { drainAndShutdown } from "../lifecycle";
5353
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
54-
import { estimateComboCost, estimateRequestCost, effectiveServiceTier, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
54+
import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
5555
import type { PersistedUsageAttempt } from "../../usage/log";
5656
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
5757
import { applySystemEnvToggle } from "../system-env";
@@ -124,7 +124,7 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
124124
}
125125

126126
export function costResult(entry: MetricSource): CostResult {
127-
const tier = effectiveServiceTier(entry);
127+
const tier = serviceTierContext(entry);
128128
const estimate = entry.attempts?.length
129129
? estimateComboCost(entry.attempts, undefined, tier)
130130
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });

src/usage/cost.ts

4.19 KB
Binary file not shown.

src/usage/expected-prices.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export interface ExpectedPriceOverlay {
3030
}
3131

3232
const GEMINI_31_PRO: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 };
33+
const GPT56_SOL: Cost4 = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 };
34+
const GPT56_TERRA: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 };
35+
const GPT56_LUNA: Cost4 = { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 };
3336
const GEMINI_36_FLASH: Cost4 = { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 };
3437
const MINIMAX_M21_HIGHSPEED: Cost4 = { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 };
3538
const KIMI_K3: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 };
@@ -50,6 +53,7 @@ const ANTHROPIC_PRICING = "https://platform.claude.com/docs/en/about-claude/pric
5053

5154
const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-07-22); cacheWrite=0: storage is billed per-hour, not per-token";
5255
const MINIMAX_PRICING = "https://platform.minimax.io/docs/guides/pricing-paygo";
56+
const OPENAI_GPT56_PRICING = "https://developers.openai.com/api/docs/pricing";
5357
const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after";
5458
// Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the
5559
// cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified.
@@ -77,6 +81,14 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
7781
// base model's standard rate per the official Billing FAQ).
7882
{ provider: "google-antigravity", modelId: "gemini-3.6-flash", cost4: GEMINI_36_FLASH, source: `collapsed base ID ${GEMINI_PRICING}`, verifiedAt: "2026-07-22", status: "verified" },
7983
{ provider: "google-antigravity", modelId: "gemini-3.1-pro", cost4: GEMINI_31_PRO, source: `collapsed base ID ${GEMINI_PRICING}`, verifiedAt: "2026-07-22", status: "verified" },
84+
// OpenAI GPT-5.6 `-pro` virtual selections. The virtual resolver keeps the SELECTED id in
85+
// the usage log and records the wire model separately, and cost resolution deliberately
86+
// does not fall back through resolvedModel — so without these rows every `-pro` request
87+
// resolved to null and rendered no cost estimate at all (#908 audit, runtime-verified).
88+
// Pro reasoning bills at the base model's published API rate; the suffix is an effort knob.
89+
{ provider: "openai-apikey", modelId: "gpt-5.6-sol-pro", cost4: GPT56_SOL, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" },
90+
{ provider: "openai-apikey", modelId: "gpt-5.6-terra-pro", cost4: GPT56_TERRA, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" },
91+
{ provider: "openai-apikey", modelId: "gpt-5.6-luna-pro", cost4: GPT56_LUNA, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" },
8092
{ provider: "google-antigravity", modelId: "gemini-3.1-pro-low", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
8193
{ provider: "google-antigravity", modelId: "gemini-3.1-pro-high", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
8294
{ provider: "google-antigravity", modelId: "gemini-pro-agent", cost4: GEMINI_31_PRO, source: `wire id for gemini-3.1-pro high ${GEMINI_PRICING}`, verifiedAt: "2026-07-23", status: "verified-derived" },
@@ -162,3 +174,102 @@ export const PRIORITY_MULTIPLIERS: Readonly<Record<string, number>> = {
162174
export function resolvePriorityMultiplier(modelId: string): number {
163175
return PRIORITY_MULTIPLIERS[modelId] ?? 1;
164176
}
177+
178+
/**
179+
* Long-context pricing tiers (#908). Several vendors reprice the ENTIRE request
180+
* once the prompt crosses a published input-token threshold, so a flat Cost4
181+
* cannot express it.
182+
*
183+
* The threshold is measured on RAW `usage.inputTokens` (total prompt size,
184+
* including cache reads/writes) — never on normalized billable input, which has
185+
* already had cache tokens subtracted. A 280k prompt with a 200k cache read has
186+
* 80k billable input and still crosses OpenAI's 272k boundary; deciding after
187+
* normalization would under-bill exactly the cache-heavy long requests.
188+
*
189+
* Rules are exact provider+model matches. No case folding: the jawcode bundle
190+
* carries BOTH `minimax-m3` and `MiniMax-M3` at different rates, so folding
191+
* would select the wrong base row. No model-level fallback: routed resellers
192+
* (Cursor, OpenRouter) share model slugs but price independently.
193+
*/
194+
export interface ContextTier {
195+
provider: string;
196+
modelId: string;
197+
/** Long rates apply once raw input tokens pass this boundary. */
198+
thresholdInputTokens: number;
199+
/** true = `>=` threshold (xAI), false = `>` threshold (OpenAI, MiniMax). */
200+
inclusive: boolean;
201+
/** Per-field factor from the short rate to the published long rate. */
202+
multiplier: Cost4;
203+
source: string;
204+
verifiedAt: string;
205+
}
206+
207+
/**
208+
* OpenAI GPT-5.6: "Prompts with >272K input tokens are priced at 2x input and
209+
* 1.5x output for the full request." Cached input and cache writes also double,
210+
* per the published short/long columns.
211+
*/
212+
const OPENAI_LONG_CONTEXT: Cost4 = { input: 2, output: 1.5, cacheRead: 2, cacheWrite: 2 };
213+
/** xAI and MiniMax double every rate uniformly past their thresholds. */
214+
const UNIFORM_DOUBLE: Cost4 = { input: 2, output: 2, cacheRead: 2, cacheWrite: 2 };
215+
216+
const OPENAI_PRICING_DOC = "https://developers.openai.com/api/docs/pricing";
217+
const OPENAI_GPT56_CONTEXT_MODELS = [
218+
"gpt-5.6-sol",
219+
"gpt-5.6-terra",
220+
"gpt-5.6-luna",
221+
// Virtual `-pro` selections keep their own id in usage logs (the wire model is
222+
// recorded separately), so they need their own rows or they silently skip the tier.
223+
"gpt-5.6-sol-pro",
224+
"gpt-5.6-terra-pro",
225+
"gpt-5.6-luna-pro",
226+
];
227+
228+
export const CONTEXT_TIERS: readonly ContextTier[] = [
229+
...["openai", "openai-apikey"].flatMap(provider =>
230+
OPENAI_GPT56_CONTEXT_MODELS.map((modelId): ContextTier => ({
231+
provider,
232+
modelId,
233+
thresholdInputTokens: 272_000,
234+
inclusive: false,
235+
multiplier: OPENAI_LONG_CONTEXT,
236+
source: OPENAI_PRICING_DOC,
237+
verifiedAt: "2026-08-03",
238+
})),
239+
),
240+
{
241+
provider: "xai",
242+
modelId: "grok-4.5",
243+
thresholdInputTokens: 200_000,
244+
inclusive: true,
245+
multiplier: UNIFORM_DOUBLE,
246+
source: "https://docs.x.ai/developers/pricing",
247+
verifiedAt: "2026-08-03",
248+
},
249+
...["minimax", "minimax-cn"].map((provider): ContextTier => ({
250+
provider,
251+
modelId: "MiniMax-M3",
252+
thresholdInputTokens: 512_000,
253+
inclusive: false,
254+
multiplier: UNIFORM_DOUBLE,
255+
source: "https://platform.minimax.io/docs/guides/pricing-paygo",
256+
verifiedAt: "2026-08-03",
257+
})),
258+
];
259+
260+
/** Exact provider+model context-tier lookup. No fuzzy matching, no case folding. */
261+
export function findContextTier(
262+
provider: string,
263+
modelId: string,
264+
tiers: readonly ContextTier[] = CONTEXT_TIERS,
265+
): ContextTier | undefined {
266+
return tiers.find(tier => tier.provider === provider && tier.modelId === modelId);
267+
}
268+
269+
/** Whether a raw input-token count crosses the tier's published boundary. */
270+
export function isLongContext(tier: ContextTier, rawInputTokens: number): boolean {
271+
if (!Number.isFinite(rawInputTokens)) return false;
272+
return tier.inclusive
273+
? rawInputTokens >= tier.thresholdInputTokens
274+
: rawInputTokens > tier.thresholdInputTokens;
275+
}

src/usage/summary.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { baseProviderLabel } from "../providers/label";
22
import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
33
import { usageDisplayTotalTokens } from "./totals";
44
import type { PersistedUsageEntry, UsageStatus } from "./log";
5-
import { estimateComboCost, estimateRequestCost, effectiveServiceTier } from "./cost";
5+
import { estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost";
66

77
export type UsageRange = "7d" | "30d" | "all";
88
export type UsageSurface = "all" | "codex" | "claude" | "grok";
@@ -286,7 +286,7 @@ function addEstimatedCost(
286286
totals.unmeteredRequests += 1;
287287
return;
288288
}
289-
const tier = effectiveServiceTier(entry);
289+
const tier = serviceTierContext(entry);
290290
const estimate = entry.attempts?.length
291291
? estimateComboCost(entry.attempts, undefined, tier)
292292
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
@@ -415,7 +415,7 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage
415415
}
416416
// Accumulate per-model estimated cost
417417
for (const entry of entries) {
418-
const tier = effectiveServiceTier(entry);
418+
const tier = serviceTierContext(entry);
419419
const estimate = entry.attempts?.length
420420
? estimateComboCost(entry.attempts, undefined, tier)
421421
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
@@ -524,7 +524,7 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us
524524
}
525525
}
526526
for (const entry of entries) {
527-
const tier = effectiveServiceTier(entry);
527+
const tier = serviceTierContext(entry);
528528
const estimate = entry.attempts?.length
529529
? estimateComboCost(entry.attempts, undefined, tier)
530530
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });

0 commit comments

Comments
 (0)