diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 29cdde2e0..5880b0340 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -51,7 +51,7 @@ import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustom import type { DesktopProfileModel } from "../../claude/desktop-profile"; import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, effectiveServiceTier, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; +import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; @@ -124,7 +124,7 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea } export function costResult(entry: MetricSource): CostResult { - const tier = effectiveServiceTier(entry); + const tier = serviceTierContext(entry); const estimate = entry.attempts?.length ? estimateComboCost(entry.attempts, undefined, tier) : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 5deaa0026..45f78c626 100644 Binary files a/src/usage/cost.ts and b/src/usage/cost.ts differ diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 26b205bab..4d2d62ed4 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -30,6 +30,9 @@ export interface ExpectedPriceOverlay { } const GEMINI_31_PRO: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }; +const GPT56_SOL: Cost4 = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }; +const GPT56_TERRA: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }; +const GPT56_LUNA: Cost4 = { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }; const GEMINI_36_FLASH: Cost4 = { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }; const MINIMAX_M21_HIGHSPEED: Cost4 = { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 }; 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 const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-07-22); cacheWrite=0: storage is billed per-hour, not per-token"; const MINIMAX_PRICING = "https://platform.minimax.io/docs/guides/pricing-paygo"; +const OPENAI_GPT56_PRICING = "https://developers.openai.com/api/docs/pricing"; const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after"; // Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the // 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[] = [ // base model's standard rate per the official Billing FAQ). { provider: "google-antigravity", modelId: "gemini-3.6-flash", cost4: GEMINI_36_FLASH, source: `collapsed base ID ${GEMINI_PRICING}`, verifiedAt: "2026-07-22", status: "verified" }, { provider: "google-antigravity", modelId: "gemini-3.1-pro", cost4: GEMINI_31_PRO, source: `collapsed base ID ${GEMINI_PRICING}`, verifiedAt: "2026-07-22", status: "verified" }, + // OpenAI GPT-5.6 `-pro` virtual selections. The virtual resolver keeps the SELECTED id in + // the usage log and records the wire model separately, and cost resolution deliberately + // does not fall back through resolvedModel — so without these rows every `-pro` request + // resolved to null and rendered no cost estimate at all (#908 audit, runtime-verified). + // Pro reasoning bills at the base model's published API rate; the suffix is an effort knob. + { 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" }, + { 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" }, + { 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" }, { 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" }, { 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" }, { 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> = { export function resolvePriorityMultiplier(modelId: string): number { return PRIORITY_MULTIPLIERS[modelId] ?? 1; } + +/** + * Long-context pricing tiers (#908). Several vendors reprice the ENTIRE request + * once the prompt crosses a published input-token threshold, so a flat Cost4 + * cannot express it. + * + * The threshold is measured on RAW `usage.inputTokens` (total prompt size, + * including cache reads/writes) — never on normalized billable input, which has + * already had cache tokens subtracted. A 280k prompt with a 200k cache read has + * 80k billable input and still crosses OpenAI's 272k boundary; deciding after + * normalization would under-bill exactly the cache-heavy long requests. + * + * Rules are exact provider+model matches. No case folding: the jawcode bundle + * carries BOTH `minimax-m3` and `MiniMax-M3` at different rates, so folding + * would select the wrong base row. No model-level fallback: routed resellers + * (Cursor, OpenRouter) share model slugs but price independently. + */ +export interface ContextTier { + provider: string; + modelId: string; + /** Long rates apply once raw input tokens pass this boundary. */ + thresholdInputTokens: number; + /** true = `>=` threshold (xAI), false = `>` threshold (OpenAI, MiniMax). */ + inclusive: boolean; + /** Per-field factor from the short rate to the published long rate. */ + multiplier: Cost4; + source: string; + verifiedAt: string; +} + +/** + * OpenAI GPT-5.6: "Prompts with >272K input tokens are priced at 2x input and + * 1.5x output for the full request." Cached input and cache writes also double, + * per the published short/long columns. + */ +const OPENAI_LONG_CONTEXT: Cost4 = { input: 2, output: 1.5, cacheRead: 2, cacheWrite: 2 }; +/** xAI and MiniMax double every rate uniformly past their thresholds. */ +const UNIFORM_DOUBLE: Cost4 = { input: 2, output: 2, cacheRead: 2, cacheWrite: 2 }; + +const OPENAI_PRICING_DOC = "https://developers.openai.com/api/docs/pricing"; +const OPENAI_GPT56_CONTEXT_MODELS = [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + // Virtual `-pro` selections keep their own id in usage logs (the wire model is + // recorded separately), so they need their own rows or they silently skip the tier. + "gpt-5.6-sol-pro", + "gpt-5.6-terra-pro", + "gpt-5.6-luna-pro", +]; + +export const CONTEXT_TIERS: readonly ContextTier[] = [ + ...["openai", "openai-apikey"].flatMap(provider => + OPENAI_GPT56_CONTEXT_MODELS.map((modelId): ContextTier => ({ + provider, + modelId, + thresholdInputTokens: 272_000, + inclusive: false, + multiplier: OPENAI_LONG_CONTEXT, + source: OPENAI_PRICING_DOC, + verifiedAt: "2026-08-03", + })), + ), + { + provider: "xai", + modelId: "grok-4.5", + thresholdInputTokens: 200_000, + inclusive: true, + multiplier: UNIFORM_DOUBLE, + source: "https://docs.x.ai/developers/pricing", + verifiedAt: "2026-08-03", + }, + ...["minimax", "minimax-cn"].map((provider): ContextTier => ({ + provider, + modelId: "MiniMax-M3", + thresholdInputTokens: 512_000, + inclusive: false, + multiplier: UNIFORM_DOUBLE, + source: "https://platform.minimax.io/docs/guides/pricing-paygo", + verifiedAt: "2026-08-03", + })), +]; + +/** Exact provider+model context-tier lookup. No fuzzy matching, no case folding. */ +export function findContextTier( + provider: string, + modelId: string, + tiers: readonly ContextTier[] = CONTEXT_TIERS, +): ContextTier | undefined { + return tiers.find(tier => tier.provider === provider && tier.modelId === modelId); +} + +/** Whether a raw input-token count crosses the tier's published boundary. */ +export function isLongContext(tier: ContextTier, rawInputTokens: number): boolean { + if (!Number.isFinite(rawInputTokens)) return false; + return tier.inclusive + ? rawInputTokens >= tier.thresholdInputTokens + : rawInputTokens > tier.thresholdInputTokens; +} diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 45e077bf4..8337505ed 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -2,7 +2,7 @@ import { baseProviderLabel } from "../providers/label"; import { canonicalAntigravityUsageModel } from "../providers/antigravity-models"; import { usageDisplayTotalTokens } from "./totals"; import type { PersistedUsageEntry, UsageStatus } from "./log"; -import { estimateComboCost, estimateRequestCost, effectiveServiceTier } from "./cost"; +import { estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost"; export type UsageRange = "7d" | "30d" | "all"; export type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -286,7 +286,7 @@ function addEstimatedCost( totals.unmeteredRequests += 1; return; } - const tier = effectiveServiceTier(entry); + const tier = serviceTierContext(entry); const estimate = entry.attempts?.length ? estimateComboCost(entry.attempts, undefined, tier) : 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 } // Accumulate per-model estimated cost for (const entry of entries) { - const tier = effectiveServiceTier(entry); + const tier = serviceTierContext(entry); const estimate = entry.attempts?.length ? estimateComboCost(entry.attempts, undefined, tier) : 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 } } for (const entry of entries) { - const tier = effectiveServiceTier(entry); + const tier = serviceTierContext(entry); const estimate = entry.attempts?.length ? estimateComboCost(entry.attempts, undefined, tier) : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index d77a5c329..f4e0d114f 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -12,6 +12,7 @@ import { import { EXPECTED_PRICE_OVERLAYS, PRIORITY_MULTIPLIERS, + CONTEXT_TIERS, findExpectedPriceOverlay, resolvePriorityMultiplier, type ExpectedPriceOverlay, @@ -229,8 +230,8 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 48 keys, including Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(48); + test("16. shipped overlay membership: 51 keys, including Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(51); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [ @@ -405,17 +406,19 @@ describe("priority (Fast) service tier multiplier", () => { { provider: "openai", modelId: "gpt-5.5", cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, source: "test", verifiedAt: "2026-07-24", status: "verified" }, { provider: "openai", modelId: "gpt-5.3-codex-spark", cost4: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, source: "test", verifiedAt: "2026-07-24", status: "verified" }, ]; - const usage = { inputTokens: 1_000_000, outputTokens: 100_000 }; + // Below OpenAI's 272k long-context boundary on purpose: these cases isolate the Fast + // multiplier, and a 1M-token prompt would silently also trip the context tier (#908). + const usage = { inputTokens: 200_000, outputTokens: 20_000 }; test("P1. priority tier applies 2x multiplier for gpt-5.6-sol", () => { const base = estimateRequestCost({ provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, overlays); const fast = estimateRequestCost({ provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage, serviceTier: "priority" }, overlays); expect(base).not.toBeNull(); expect(fast).not.toBeNull(); - // base: 5*1 + 30*0.1 = 8.0 - expect(base!.cost.total).toBeCloseTo(8.0, 9); - // fast: 2x => 16.0 - expect(fast!.cost.total).toBeCloseTo(16.0, 9); + // base: 5*0.2 + 30*0.02 = 1.6 + expect(base!.cost.total).toBeCloseTo(1.6, 9); + // fast: 2x => 3.2 + expect(fast!.cost.total).toBeCloseTo(3.2, 9); expect(fast!.priorityMultiplier).toBe(2); expect(base!.priorityMultiplier).toBeUndefined(); }); @@ -425,32 +428,32 @@ describe("priority (Fast) service tier multiplier", () => { const fast = estimateRequestCost({ provider: "openai", model: "gpt-5.6-luna", usageStatus: "reported", usage, serviceTier: "priority" }); expect(base).not.toBeNull(); expect(fast).not.toBeNull(); - // Standard: $1 input + $0.60 output = $1.60. Fast: $0.40 + $0.24 = $0.64. - expect(base!.cost.total).toBeCloseTo(1.6, 9); - expect(fast!.cost.total).toBeCloseTo(0.64, 9); + // Standard: $0.20 input + $0.12 output = $0.32. Fast (0.4x): $0.128. + expect(base!.cost.total).toBeCloseTo(0.32, 9); + expect(fast!.cost.total).toBeCloseTo(0.128, 9); expect(fast!.priorityMultiplier).toBe(0.4); }); test("P2. priority tier applies 2.5x multiplier for gpt-5.5", () => { const base = estimateRequestCost({ provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage }, overlays); const fast = estimateRequestCost({ provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage, serviceTier: "priority" }, overlays); - expect(base!.cost.total).toBeCloseTo(8.0, 9); - // 2.5x => 20.0 - expect(fast!.cost.total).toBeCloseTo(20.0, 9); + expect(base!.cost.total).toBeCloseTo(1.6, 9); + // 2.5x => 4.0 + expect(fast!.cost.total).toBeCloseTo(4.0, 9); expect(fast!.priorityMultiplier).toBe(2.5); }); test("P3. no service tier => base price unchanged (regression)", () => { const est = estimateRequestCost({ provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, overlays); - expect(est!.cost.total).toBeCloseTo(8.0, 9); + expect(est!.cost.total).toBeCloseTo(1.6, 9); expect(est!.priorityMultiplier).toBeUndefined(); }); test("P4. unknown model + priority => multiplier 1 (fallback)", () => { const est = estimateRequestCost({ provider: "openai", model: "gpt-5.3-codex-spark", usageStatus: "reported", usage, serviceTier: "priority" }, overlays); expect(est).not.toBeNull(); - // base: 1.75*1 + 14*0.1 = 3.15; no multiplier listed => stays 3.15 - expect(est!.cost.total).toBeCloseTo(3.15, 9); + // base: 1.75*0.2 + 14*0.02 = 0.63; no multiplier listed => stays 0.63 + expect(est!.cost.total).toBeCloseTo(0.63, 9); expect(est!.priorityMultiplier).toBeUndefined(); }); @@ -461,7 +464,7 @@ describe("priority (Fast) service tier multiplier", () => { const est = estimateRequestCost({ provider: "openrouter", model: "gpt-5.6-sol", usageStatus: "reported", usage, serviceTier: "priority" }, customOverlays); expect(est).not.toBeNull(); // provider gate: openrouter is not openai => no multiplier - expect(est!.cost.total).toBeCloseTo(8.0, 9); + expect(est!.cost.total).toBeCloseTo(1.6, 9); expect(est!.priorityMultiplier).toBeUndefined(); }); @@ -470,7 +473,7 @@ describe("priority (Fast) service tier multiplier", () => { { ordinal: 1, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, ], overlays, "priority"); expect(combo).not.toBeNull(); - expect(combo!.cost.total).toBeCloseTo(16.0, 9); + expect(combo!.cost.total).toBeCloseTo(3.2, 9); expect(combo!.priorityMultiplier).toBe(2); }); @@ -506,8 +509,136 @@ describe("priority (Fast) service tier multiplier", () => { test("P10. attempt cost with priority tier", () => { const base = estimateAttemptCost({ ordinal: 1, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, overlays); const fast = estimateAttemptCost({ ordinal: 1, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, overlays, "priority"); - expect(base!.cost.total).toBeCloseTo(8.0, 9); - expect(fast!.cost.total).toBeCloseTo(16.0, 9); + expect(base!.cost.total).toBeCloseTo(1.6, 9); + expect(fast!.cost.total).toBeCloseTo(3.2, 9); expect(fast!.priorityMultiplier).toBe(2); }); }); + +describe("long-context pricing tiers (#908)", () => { + const SOL: ExpectedPriceOverlay[] = [ + { provider: "openai", modelId: "gpt-5.6-sol", cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + { provider: "openai", modelId: "gpt-5.3-codex-spark", cost4: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + ]; + const sol = (usage: Record, serviceTier?: Parameters[0]["serviceTier"]) => + estimateRequestCost({ provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage, serviceTier }, SOL); + + test("L1. OpenAI boundary is exclusive: 272,000 is standard, 272,001 is long", () => { + const at = sol({ inputTokens: 272_000, outputTokens: 10_000 }); + const over = sol({ inputTokens: 272_001, outputTokens: 10_000 }); + expect(at!.contextTier).toBeUndefined(); + expect(over!.contextTier).toBe("long"); + // 2x input, 1.5x output — not a uniform doubling. Compare at equal token + // counts so the one-token difference across the boundary does not skew it. + const overSameTokens = sol({ inputTokens: 272_001, outputTokens: 10_000 })!; + expect(overSameTokens.cost.output).toBeCloseTo(at!.cost.output * 1.5, 9); + expect(overSameTokens.cost.input / (272_001 / 1e6)).toBeCloseTo(10, 9); + expect(at!.cost.input / (272_000 / 1e6)).toBeCloseTo(5, 9); + }); + + test("L2. worked example: 300k in + 20k out is $2.10 standard, $3.90 long", () => { + // The tier is what makes this $3.90; without it the estimator reports $2.10. + const est = sol({ inputTokens: 300_000, outputTokens: 20_000 }); + expect(est!.contextTier).toBe("long"); + expect(est!.cost.total).toBeCloseTo(3.9, 9); + const short = 300_000 / 1e6 * 5 + 20_000 / 1e6 * 30; + expect(short).toBeCloseTo(2.1, 9); + }); + + test("L3. threshold reads RAW input, not cache-normalized billable input", () => { + // 280k prompt with a 200k cache read: 80k billable input, but the vendor + // threshold is measured on the whole prompt. Deciding after normalization + // would under-bill exactly the cache-heavy long requests. + const est = sol({ inputTokens: 280_000, outputTokens: 1_000, cachedInputTokens: 200_000 }); + expect(est!.tokens.input).toBe(80_000); + expect(est!.contextTier).toBe("long"); + expect(est!.cost.cacheRead).toBeCloseTo(200_000 / 1e6 * 0.5 * 2, 9); + }); + + test("L4. xAI boundary is inclusive: 199,999 standard, 200,000 long", () => { + const overlays: ExpectedPriceOverlay[] = [ + { provider: "xai", modelId: "grok-4.5", cost4: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + ]; + const at = (n: number) => estimateRequestCost({ provider: "xai", model: "grok-4.5", usageStatus: "reported", usage: { inputTokens: n, outputTokens: 1_000 } }, overlays); + expect(at(199_999)!.contextTier).toBeUndefined(); + expect(at(200_000)!.contextTier).toBe("long"); + }); + + test("L5. MiniMax casing is exact: MiniMax-M3 tiers, minimax-m3 does not", () => { + const overlays: ExpectedPriceOverlay[] = [ + { provider: "minimax", modelId: "MiniMax-M3", cost4: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + { provider: "minimax", modelId: "minimax-m3", cost4: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + ]; + const at = (model: string, n: number) => estimateRequestCost({ provider: "minimax", model, usageStatus: "reported", usage: { inputTokens: n, outputTokens: 1_000 } }, overlays); + expect(at("MiniMax-M3", 512_000)!.contextTier).toBeUndefined(); + expect(at("MiniMax-M3", 512_001)!.contextTier).toBe("long"); + // The bundle carries both ids at different rates; case-folding would pick the wrong row. + expect(at("minimax-m3", 512_001)!.contextTier).toBeUndefined(); + }); + + test("L6. routed resellers price independently: cursor/openrouter never tier", () => { + const overlays: ExpectedPriceOverlay[] = [ + { provider: "cursor", modelId: "gpt-5.6-sol", cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + { provider: "openrouter", modelId: "gpt-5.6-sol", cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, source: "test", verifiedAt: "2026-08-03", status: "verified" }, + ]; + for (const provider of ["cursor", "openrouter"]) { + const est = estimateRequestCost({ provider, model: "gpt-5.6-sol", usageStatus: "reported", usage: { inputTokens: 1_000_000, outputTokens: 10_000 } }, overlays); + expect(est!.contextTier).toBeUndefined(); + } + }); + + test("L7. a model with no published tier is unaffected at any size", () => { + const est = estimateRequestCost({ provider: "openai", model: "gpt-5.3-codex-spark", usageStatus: "reported", usage: { inputTokens: 5_000_000, outputTokens: 10_000 } }, SOL); + expect(est!.contextTier).toBeUndefined(); + expect(est!.cost.total).toBeCloseTo(5_000_000 / 1e6 * 1.75 + 10_000 / 1e6 * 14, 9); + }); + + test("L8. Fast and long context are mutually exclusive, by PROVENANCE", () => { + const usage = { inputTokens: 300_000, outputTokens: 20_000 }; + // Response-confirmed Fast: OpenAI does not serve long context in Fast mode, + // so the request really was Fast and the context tier must not apply. + const confirmed = sol(usage, { responseServiceTier: "priority" }); + expect(confirmed!.contextTier).toBeUndefined(); + expect(confirmed!.priorityMultiplier).toBe(2); + expect(confirmed!.cost.total).toBeCloseTo(4.2, 9); + + // Requested/configured only, with no response confirmation: a >272k request + // cannot have been served as Fast, so it was downgraded and bills long. + // Suppressing the tier here would under-bill exactly the downgraded request. + for (const tier of [{ requestedServiceTier: "priority" }, { configuredServiceTier: "priority" }]) { + const downgraded = sol(usage, tier); + expect(downgraded!.contextTier).toBe("long"); + expect(downgraded!.cost.total).toBeCloseTo(3.9, 9); + } + }); + + test("L9. combo carries the tier when any attempt is long", () => { + const combo = estimateComboCost([ + { ordinal: 1, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage: { inputTokens: 300_000, outputTokens: 10_000 } }, + { ordinal: 2, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage: { inputTokens: 1_000, outputTokens: 10_000 } }, + ], SOL); + expect(combo!.attempts![0]!.contextTier).toBe("long"); + expect(combo!.attempts![1]!.contextTier).toBeUndefined(); + expect(combo!.contextTier).toBe("long"); + }); + + test("L10. -pro virtual aliases are priceable AND tier (regression: returned null)", () => { + for (const model of ["gpt-5.6-sol-pro", "gpt-5.6-terra-pro", "gpt-5.6-luna-pro"]) { + // Shipped overlays on purpose: these ids had no base price at all, so the + // estimator returned null and the dashboard showed no cost for them. + const priced = estimateRequestCost({ provider: "openai-apikey", model, usageStatus: "reported", usage: { inputTokens: 1_000, outputTokens: 1_000 } }); + expect(priced).not.toBeNull(); + const long = estimateRequestCost({ provider: "openai-apikey", model, usageStatus: "reported", usage: { inputTokens: 272_001, outputTokens: 1_000 } }); + expect(long!.contextTier).toBe("long"); + } + }); + + test("L11. every tier rule records a source and a verification date", () => { + expect(CONTEXT_TIERS.length).toBeGreaterThan(0); + for (const tier of CONTEXT_TIERS) { + expect(tier.source).toMatch(/^https:\/\//); + expect(tier.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(tier.thresholdInputTokens).toBeGreaterThan(0); + } + }); +});