From 93b6571d6e06d3f4b383c6d7bd342cd090da1442 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 09:10:36 +0000 Subject: [PATCH 1/4] test(server): usage prices long-context requests at above-tier rates (red) Failing tests for pingdotgg/t3code#25414: a Codex gpt-6-sol turn above 272K prompt tokens, a Claude prompt above 100K, and a priority request above 272K are all priced at base rates. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 60 ++++++++++++++++++++ apps/server/src/usage/usagePricing.test.ts | 66 ++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 4cd1143ac3787..4fc6343ff9f6f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1933,6 +1933,66 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("prices a Codex turn above 272K prompt tokens at the long-context rates", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const dir = NodePath.join(home, "codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "rollout.jsonl"), + [ + { type: "turn_context", payload: { model: "gpt-6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: 300_000, + cached_input_tokens: 280_000, + output_tokens: 1_000, + reasoning_output_tokens: 0, + total_tokens: 301_000, + }, + }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-service-long-context-test", + home, + settings, + ratesDocument: { + "gpt-6-sol": { + input_cost_per_token: 2e-6, + cache_read_input_token_cost: 2e-7, + output_cost_per_token: 1e-5, + input_cost_per_token_above_272k_tokens: 4e-6, + cache_read_input_token_cost_above_272k_tokens: 4e-7, + output_cost_per_token_above_272k_tokens: 1.5e-5, + }, + }, + }), + ), + ); + const bucket = summary.buckets.find((bucket) => bucket.model === "gpt-6-sol"); + assert.strictEqual(bucket?.costSource, "modelPriced"); + assert.closeTo(bucket?.costUsd ?? NaN, 0.207, 1e-9); + }).pipe(Effect.scoped), + ); + it.live( "retains Codex reconciliation and partial coverage through persisted restart, append and repair", () => diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ce6d6f74eff7..3139192d5f355 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -195,6 +195,72 @@ describe("usage pricing", () => { expect(cost("gpt-6-sol", "ultrafast")).toBe(cost("gpt-6-sol", "standard")); }); + it("prices a request above the model's long-context threshold at its above-tier rates", () => { + const table = parseRateTable({ + "claude-haiku-5-5": { + input_cost_per_token: 1e-6, + output_cost_per_token: 5e-6, + cache_read_input_token_cost: 1e-7, + cache_creation_input_token_cost: 1.25e-6, + input_cost_per_token_above_100k_tokens: 2e-6, + output_cost_per_token_above_100k_tokens: 7.5e-6, + cache_read_input_token_cost_above_100k_tokens: 2e-7, + cache_creation_input_token_cost_above_100k_tokens: 2.5e-6, + }, + "gpt-6-sol": { + ...rate(2e-6, 2e-7), + input_cost_per_token_priority: 4e-6, + output_cost_per_token_priority: 2e-5, + cache_read_input_token_cost_priority: 4e-7, + input_cost_per_token_above_272k_tokens_priority: 8e-6, + output_cost_per_token_above_272k_tokens_priority: 3e-5, + cache_read_input_token_cost_above_272k_tokens_priority: 8e-7, + }, + }); + const longRequest = ( + model: string, + speed: UsageSpeed, + totals: { uncached: number; cacheRead: number; cacheWrite: number; output: number }, + ) => ({ + model, + speed, + reportedCostUsd: null, + totals: { + uncachedInputTokens: totals.uncached, + cachedInputTokens: totals.cacheRead, + cacheCreationTokens: totals.cacheWrite, + outputTokens: totals.output, + reasoningTokens: 0, + }, + }); + + // The Claude prompt is input + cache read + cache write: 120K, above the 100K tier. + expect + .soft( + priceUsage( + table, + longRequest("claude-haiku-5-5", "standard", { + uncached: 10_000, + cacheRead: 100_000, + cacheWrite: 10_000, + output: 1_000, + }), + ).costUsd, + ) + .toBeCloseTo(10_000 * 2e-6 + 100_000 * 2e-7 + 10_000 * 2.5e-6 + 1_000 * 7.5e-6, 12); + expect( + priceUsage( + table, + longRequest("gpt-6-sol", "fast", { + uncached: 20_000, + cacheRead: 280_000, + cacheWrite: 0, + output: 1_000, + }), + ).costUsd, + ).toBeCloseTo(20_000 * 8e-6 + 280_000 * 8e-7 + 1_000 * 3e-5, 12); + }); + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; From f5a43a82bb3c5ab4e4343bd66357b71638a8cf5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 09:18:04 +0000 Subject: [PATCH 2/4] fix(server): usage prices long-context requests at above-tier rates Requests whose prompt (input + cache read + cache write) exceeds a model's LiteLLM *_above_k_tokens threshold now bill at that tier, including its _priority/_ultrafast variants. The tier is chosen per record, so many small requests stay at base rates. Fields a tier omits fall back to base rates; overrides and untiered models are unchanged. Fixes pingdotgg/t3code#25414 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 54 +++++---- .../server/src/usage/usageAggregation.test.ts | 38 +++++- apps/server/src/usage/usagePricing.test.ts | 54 +++++++++ apps/server/src/usage/usagePricing.ts | 109 +++++++++++++++--- 4 files changed, 211 insertions(+), 44 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 4fc6343ff9f6f..15bcb9d966dfc 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1964,32 +1964,36 @@ describe("UsageService", () => { .join("\n") + "\n", ); }); - const summary = yield* Effect.gen(function* () { - const service = yield* UsageService.make; - return yield* service.readSummary(WINDOW); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "usage-service-long-context-test", - home, - settings, - ratesDocument: { - "gpt-6-sol": { - input_cost_per_token: 2e-6, - cache_read_input_token_cost: 2e-7, - output_cost_per_token: 1e-5, - input_cost_per_token_above_272k_tokens: 4e-6, - cache_read_input_token_cost_above_272k_tokens: 4e-7, - output_cost_per_token_above_272k_tokens: 1.5e-5, + // The restart reads the persisted scan and rate caches while the rates endpoint fails. + for (const ratesStatus of [200, 500]) { + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-service-long-context-test", + home, + settings, + ratesStatus: () => ratesStatus, + ratesDocument: { + "gpt-6-sol": { + input_cost_per_token: 2e-6, + cache_read_input_token_cost: 2e-7, + output_cost_per_token: 1e-5, + input_cost_per_token_above_272k_tokens: 4e-6, + cache_read_input_token_cost_above_272k_tokens: 4e-7, + output_cost_per_token_above_272k_tokens: 1.5e-5, + }, }, - }, - }), - ), - ); - const bucket = summary.buckets.find((bucket) => bucket.model === "gpt-6-sol"); - assert.strictEqual(bucket?.costSource, "modelPriced"); - assert.closeTo(bucket?.costUsd ?? NaN, 0.207, 1e-9); + }), + ), + ); + const bucket = summary.buckets.find((bucket) => bucket.model === "gpt-6-sol"); + assert.strictEqual(bucket?.costSource, "modelPriced"); + assert.closeTo(bucket?.costUsd ?? NaN, 0.207, 1e-9); + } }).pipe(Effect.scoped), ); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 08b3f3ae1b8c8..1bdb9d22018d5 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; -import type { RateTable } from "./usagePricing.ts"; +import { parseRateTable, type RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; const rates: RateTable = new Map([ @@ -19,6 +19,7 @@ const rates: RateTable = new Map([ cacheCreationCostPerToken: 2.5e-5, }, ultrafast: null, + contextTiers: [], }, ], ]); @@ -68,6 +69,41 @@ function aggregate( } describe("UsageAggregator", () => { + it("prices each request against the long-context threshold, not the bucket total", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution: "day", + rates: parseRateTable({ + "gpt-6-sol": { + input_cost_per_token: 2e-6, + output_cost_per_token: 1e-5, + input_cost_per_token_above_272k_tokens: 4e-6, + output_cost_per_token_above_272k_tokens: 1.5e-5, + }, + }), + }); + const request = (uncachedInputTokens: number) => + record({ + model: "gpt-6-sol", + totals: { + uncachedInputTokens, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 1_000, + reasoningTokens: 0, + }, + }); + for (let index = 0; index < 4; index += 1) aggregator.add(request(100_000)); + aggregator.add(request(300_000)); + const [bucket] = aggregator.finish().buckets; + + expect(bucket?.costUsd).toBeCloseTo( + 4 * (100_000 * 2e-6 + 1_000 * 1e-5) + 300_000 * 4e-6 + 1_000 * 1.5e-5, + 12, + ); + }); it("splits a bucket's cost by category and speed", () => { const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets; diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 3139192d5f355..36115ee864637 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -261,6 +261,60 @@ describe("usage pricing", () => { ).toBeCloseTo(20_000 * 8e-6 + 280_000 * 8e-7 + 1_000 * 3e-5, 12); }); + it("bills the long-context tier only above its threshold, filling missing tier fields from base", () => { + const table = parseRateTable({ + "gpt-6-sol": { + input_cost_per_token: 2e-6, + output_cost_per_token: 1e-5, + cache_read_input_token_cost: 2e-7, + input_cost_per_token_priority: 4e-6, + output_cost_per_token_priority: 2e-5, + input_cost_per_token_above_272k_tokens: 4e-6, + }, + }); + const cost = (prompt: number, speed: UsageSpeed = "standard") => + priceUsage(table, { + model: "gpt-6-sol", + speed, + reportedCostUsd: null, + totals: { + uncachedInputTokens: prompt - 200_000, + cachedInputTokens: 200_000, + cacheCreationTokens: 0, + outputTokens: 1_000, + reasoningTokens: 0, + }, + }).costUsd; + + expect(cost(272_000)).toBeCloseTo(72_000 * 2e-6 + 200_000 * 2e-7 + 1_000 * 1e-5, 12); + // Output keeps the base rate; cache reads keep the base discount relative to input. + expect(cost(272_001)).toBeCloseTo(72_001 * 4e-6 + 200_000 * 4e-7 + 1_000 * 1e-5, 12); + // Without a priority long-context rate, priority requests keep the priority rate. + expect(cost(272_001, "fast")).toBeCloseTo(72_001 * 4e-6 + 200_000 * 4e-7 + 1_000 * 2e-5, 12); + }); + + it("leaves price overrides and models without above-tier rates at their flat rates", () => { + const table = parseRateTable({ "gpt-6-sol": rate(2e-6) }); + const overrides = createOverrideRateTable({ + "claude-haiku-5-5": { inputCostPerMillionTokens: 1, outputCostPerMillionTokens: 5 }, + }); + const long = (model: string) => ({ + model, + speed: "standard" as const, + reportedCostUsd: null, + totals: { + uncachedInputTokens: 500_000, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + }); + + expect(priceUsage(table, long("gpt-6-sol")).costUsd).toBeCloseTo(1, 12); + expect(priceUsage(table, long("claude-haiku-5-5"), overrides).costUsd).toBeCloseTo(0.5, 12); + }); + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index fa512218d7c0c..9dfec74840e14 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -14,12 +14,23 @@ export interface TokenRates { readonly cacheCreationCostPerToken: number; } -/** Omits LiteLLM's above_272k_tokens, flex and batches variants, which transcripts do not record. */ -export interface ModelRate extends TokenRates { +interface SpeedRates extends TokenRates { readonly fast: TokenRates | null; readonly ultrafast: TokenRates | null; } +/** A request whose prompt exceeds `thresholdTokens` bills every token at this tier. */ +interface ContextTier { + readonly thresholdTokens: number; + readonly rate: SpeedRates; +} + +/** Omits LiteLLM's flex and batches variants, which transcripts do not record. */ +export interface ModelRate extends SpeedRates { + /** Sorted by descending threshold. */ + readonly contextTiers: readonly ContextTier[]; +} + export type RateTable = ReadonlyMap; export function createOverrideRateTable( @@ -37,6 +48,7 @@ export function createOverrideRateTable( (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, fast: null, ultrafast: null, + contextTiers: [], }, ]), ); @@ -53,9 +65,10 @@ function readTokenRates( entry: LiteLlmEntry, suffix: string, standard?: TokenRates, + outputFallback?: number, ): TokenRates | null { const input = finiteNumber(entry[`input_cost_per_token${suffix}`]); - const output = finiteNumber(entry[`output_cost_per_token${suffix}`]); + const output = finiteNumber(entry[`output_cost_per_token${suffix}`]) ?? outputFallback ?? null; if (input === null || output === null) return null; const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") => finiteNumber(entry[`${name}${suffix}`]) ?? @@ -89,6 +102,40 @@ function fastMultiplier(entry: LiteLlmEntry): number | null { return fast !== null && fast > 0 ? fast : null; } +function readSpeedRates(entry: LiteLlmEntry, standard: TokenRates, suffix = ""): SpeedRates { + const multiple = fastMultiplier(entry); + return { + ...standard, + fast: + multiple === null + ? readTokenRates(entry, `${suffix}_priority`, standard) + : scaleTokenRates(standard, multiple), + ultrafast: readTokenRates(entry, `${suffix}_ultrafast`, standard), + }; +} + +const CONTEXT_TIER_INPUT_KEY = + /^input_cost_per_token_above_(\d{1,7})k_tokens(?:_priority|_ultrafast)?$/; + +// Fields a tier omits fall back to base rates, so a priority-only tier still prices standard requests at base. +function readContextTiers(entry: LiteLlmEntry, base: TokenRates): ContextTier[] { + const thresholds = new Set(); + for (const key of Object.keys(entry)) { + const match = CONTEXT_TIER_INPUT_KEY.exec(key); + if (match !== null) thresholds.add(match[1]!); + } + return [...thresholds] + .map((threshold) => { + const suffix = `_above_${threshold}k_tokens`; + const standard = readTokenRates(entry, suffix, base, base.outputCostPerToken) ?? base; + return { + thresholdTokens: Number(threshold) * 1_000, + rate: readSpeedRates(entry, standard, suffix), + }; + }) + .toSorted((a, b) => b.thresholdTokens - a.thresholdTokens); +} + /** Drops models missing input or output rates to avoid understating cost. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); @@ -102,14 +149,9 @@ export function parseRateTable(document: unknown): RateTable { const key = normalizeRateKey(name); if (key.length === 0) continue; - const multiple = fastMultiplier(entry); table.set(key, { - ...standard, - fast: - multiple === null - ? readTokenRates(entry, "_priority", standard) - : scaleTokenRates(standard, multiple), - ultrafast: readTokenRates(entry, "_ultrafast", standard), + ...readSpeedRates(entry, standard), + contextTiers: readContextTiers(entry, standard), }); } @@ -141,22 +183,51 @@ function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean { ); } -// Resellers often omit speed tiers, so a missing tier fills from the other entry instead of conflicting. +function sameSpeedRates(a: SpeedRates, b: SpeedRates): boolean { + return ( + sameTokenRates(a, b) && + sameTokenRates(a.fast, b.fast) && + sameTokenRates(a.ultrafast, b.ultrafast) + ); +} + +function sameContextTiers(a: readonly ContextTier[], b: readonly ContextTier[]): boolean { + return ( + a.length === b.length && + a.every( + (tier, index) => + tier.thresholdTokens === b[index]!.thresholdTokens && + sameSpeedRates(tier.rate, b[index]!.rate), + ) + ); +} + +// Resellers often omit speed and context tiers, so a missing tier fills from the other entry instead of conflicting. function mergeAliasRate(a: ModelRate, b: ModelRate): ModelRate | null { const fast = a.fast ?? b.fast; const ultrafast = a.ultrafast ?? b.ultrafast; + const contextTiers = a.contextTiers.length > 0 ? a.contextTiers : b.contextTiers; if ( !sameTokenRates(a, b) || !sameTokenRates(fast, b.fast ?? fast) || - !sameTokenRates(ultrafast, b.ultrafast ?? ultrafast) + !sameTokenRates(ultrafast, b.ultrafast ?? ultrafast) || + (b.contextTiers.length > 0 && !sameContextTiers(contextTiers, b.contextTiers)) ) { return null; } - return { ...a, fast, ultrafast }; + return { ...a, fast, ultrafast, contextTiers }; +} + +function promptTokens(totals: UsageTokenTotals): number { + return totals.uncachedInputTokens + totals.cachedInputTokens + totals.cacheCreationTokens; } -function ratesAt(rate: ModelRate, speed: UsageSpeed): TokenRates { - return (speed === "standard" ? null : rate[speed]) ?? rate; +// A tier without the requested speed keeps the base tier's rate for that speed. +function ratesAt(rate: ModelRate, speed: UsageSpeed, totals: UsageTokenTotals): TokenRates { + const prompt = promptTokens(totals); + const tier = rate.contextTiers.find((tier) => prompt > tier.thresholdTokens)?.rate; + if (speed === "standard") return tier ?? rate; + return tier?.[speed] ?? rate[speed] ?? tier ?? rate; } function normalizeRateKey(model: string): string { @@ -256,11 +327,13 @@ export function priceUsage( return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported"); } - const listCost = costByCategory(totals, ratesAt(rate, record.speed)); + const listCost = costByCategory(totals, ratesAt(rate, record.speed, totals)); const listCostUsd = sumCategories(listCost); if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported"); const premiumUsd = - record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(totals, rate)); + record.speed === "standard" + ? 0 + : listCostUsd - sumCategories(costByCategory(totals, ratesAt(rate, "standard", totals))); const scale = reported === null ? 1 : reported / listCostUsd; return { costUsd: reported ?? listCostUsd, @@ -283,6 +356,6 @@ export function cacheSavingsUsd( const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; - const rates = ratesAt(rate, record.speed); + const rates = ratesAt(rate, record.speed, record.totals); return record.totals.cachedInputTokens * (rates.inputCostPerToken - rates.cacheReadCostPerToken); } From e25eed5ea4487f70e810dd03cba112d1c18a8f0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:11:15 +0000 Subject: [PATCH 3/4] fix(server): usage keeps a bare alias when a reseller omits a context tier's speed rates A reseller listing the same long-context tier without its priority or ultrafast rates conflicted with the full entry and dropped the bare alias. Context tiers now merge per speed the way top-level speed tiers do. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/usage/usagePricing.test.ts | 6 ++- apps/server/src/usage/usagePricing.ts | 50 +++++++++++----------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 32380988b2d0a..12810c7753817 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -359,8 +359,12 @@ describe("usage pricing", () => { ...rate(1), input_cost_per_token_priority: 2, output_cost_per_token_priority: 10, + input_cost_per_token_above_272k_tokens: 2, + input_cost_per_token_above_272k_tokens_priority: 4, + output_cost_per_token_above_272k_tokens_priority: 20, }, - "provider-b/example-model": rate(1), + "provider-b/example-model": { ...rate(1), input_cost_per_token_above_272k_tokens: 2 }, + "provider-c/example-model": rate(1), }); expect(lookupRate(table, "example-model")).toEqual( diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index ffcfaa8337e11..184b22c7810b3 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -196,39 +196,41 @@ function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean { ); } -function sameSpeedRates(a: SpeedRates, b: SpeedRates): boolean { - return ( - sameTokenRates(a, b) && - sameTokenRates(a.fast, b.fast) && - sameTokenRates(a.ultrafast, b.ultrafast) - ); -} - -function sameContextTiers(a: readonly ContextTier[], b: readonly ContextTier[]): boolean { - return ( - a.length === b.length && - a.every( - (tier, index) => - tier.thresholdTokens === b[index]!.thresholdTokens && - sameSpeedRates(tier.rate, b[index]!.rate), - ) - ); -} - // Resellers often omit speed and context tiers, so a missing tier fills from the other entry instead of conflicting. -function mergeAliasRate(a: ModelRate, b: ModelRate): ModelRate | null { +function mergeSpeedRates(a: SpeedRates, b: SpeedRates): SpeedRates | null { const fast = a.fast ?? b.fast; const ultrafast = a.ultrafast ?? b.ultrafast; - const contextTiers = a.contextTiers.length > 0 ? a.contextTiers : b.contextTiers; if ( !sameTokenRates(a, b) || !sameTokenRates(fast, b.fast ?? fast) || - !sameTokenRates(ultrafast, b.ultrafast ?? ultrafast) || - (b.contextTiers.length > 0 && !sameContextTiers(contextTiers, b.contextTiers)) + !sameTokenRates(ultrafast, b.ultrafast ?? ultrafast) ) { return null; } - return { ...a, fast, ultrafast, contextTiers }; + return { ...a, fast, ultrafast }; +} + +function mergeContextTiers( + a: readonly ContextTier[], + b: readonly ContextTier[], +): readonly ContextTier[] | null { + if (a.length === 0) return b; + if (b.length === 0) return a; + if (a.length !== b.length) return null; + const merged: ContextTier[] = []; + for (const [index, tier] of a.entries()) { + const other = b[index]!; + const rate = mergeSpeedRates(tier.rate, other.rate); + if (tier.thresholdTokens !== other.thresholdTokens || rate === null) return null; + merged.push({ thresholdTokens: tier.thresholdTokens, rate }); + } + return merged; +} + +function mergeAliasRate(a: ModelRate, b: ModelRate): ModelRate | null { + const speeds = mergeSpeedRates(a, b); + const contextTiers = mergeContextTiers(a.contextTiers, b.contextTiers); + return speeds === null || contextTiers === null ? null : { ...speeds, contextTiers }; } function promptTokens(totals: UsageTokenTotals): number { From 6fa2abee4c0c8f2aaea72701f7b561fdd8555086 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:02:41 +0000 Subject: [PATCH 4/4] test(server): usage long-context pricing tests stay at the pricing layer AI-assisted; verified with the tests and tools listed above. --- apps/server/src/usage/UsageService.test.ts | 64 ------------------- .../server/src/usage/usageAggregation.test.ts | 37 +---------- apps/server/src/usage/usagePricing.test.ts | 22 ------- 3 files changed, 1 insertion(+), 122 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 0c793a504ebd3..a3775c6aebe17 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2408,70 +2408,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("prices a Codex turn above 272K prompt tokens at the long-context rates", () => - Effect.gen(function* () { - const { home, settings } = yield* setup; - const dir = NodePath.join(home, "codex", "sessions"); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(dir, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(dir, "rollout.jsonl"), - [ - { type: "turn_context", payload: { model: "gpt-6-sol" } }, - { - type: "event_msg", - timestamp: "2026-08-01T10:00:00Z", - payload: { - type: "token_count", - info: { - last_token_usage: { - input_tokens: 300_000, - cached_input_tokens: 280_000, - output_tokens: 1_000, - reasoning_output_tokens: 0, - total_tokens: 301_000, - }, - }, - }, - }, - ] - .map((line) => encodeUnknownJsonString(line)) - .join("\n") + "\n", - ); - }); - // The restart reads the persisted scan and rate caches while the rates endpoint fails. - for (const ratesStatus of [200, 500]) { - const summary = yield* Effect.gen(function* () { - const service = yield* UsageService.make; - return yield* service.readSummary(WINDOW); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "usage-service-long-context-test", - home, - settings, - ratesStatus: () => ratesStatus, - ratesDocument: { - "gpt-6-sol": { - input_cost_per_token: 2e-6, - cache_read_input_token_cost: 2e-7, - output_cost_per_token: 1e-5, - input_cost_per_token_above_272k_tokens: 4e-6, - cache_read_input_token_cost_above_272k_tokens: 4e-7, - output_cost_per_token_above_272k_tokens: 1.5e-5, - }, - }, - }), - ), - ); - const bucket = summary.buckets.find((bucket) => bucket.model === "gpt-6-sol"); - assert.strictEqual(bucket?.costSource, "modelPriced"); - assert.closeTo(bucket?.costUsd ?? NaN, 0.207, 1e-9); - } - }).pipe(Effect.scoped), - ); - it.live( "retains Codex reconciliation and partial coverage through persisted restart, append and repair", () => diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 1bdb9d22018d5..48a1a7742cef7 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; -import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; const rates: RateTable = new Map([ @@ -69,41 +69,6 @@ function aggregate( } describe("UsageAggregator", () => { - it("prices each request against the long-context threshold, not the bucket total", () => { - const aggregator = new UsageAggregator({ - timeZone: "UTC", - sinceDay: "2026-08-01", - untilDay: "2026-08-31", - resolution: "day", - rates: parseRateTable({ - "gpt-6-sol": { - input_cost_per_token: 2e-6, - output_cost_per_token: 1e-5, - input_cost_per_token_above_272k_tokens: 4e-6, - output_cost_per_token_above_272k_tokens: 1.5e-5, - }, - }), - }); - const request = (uncachedInputTokens: number) => - record({ - model: "gpt-6-sol", - totals: { - uncachedInputTokens, - cachedInputTokens: 0, - cacheCreationTokens: 0, - outputTokens: 1_000, - reasoningTokens: 0, - }, - }); - for (let index = 0; index < 4; index += 1) aggregator.add(request(100_000)); - aggregator.add(request(300_000)); - const [bucket] = aggregator.finish().buckets; - - expect(bucket?.costUsd).toBeCloseTo( - 4 * (100_000 * 2e-6 + 1_000 * 1e-5) + 300_000 * 4e-6 + 1_000 * 1.5e-5, - 12, - ); - }); it("splits a bucket's cost by category and speed", () => { const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets; diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 12810c7753817..48f007c1b6d7d 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -293,28 +293,6 @@ describe("usage pricing", () => { expect(cost(272_001, "fast")).toBeCloseTo(72_001 * 4e-6 + 200_000 * 4e-7 + 1_000 * 2e-5, 12); }); - it("leaves price overrides and models without above-tier rates at their flat rates", () => { - const table = parseRateTable({ "gpt-6-sol": rate(2e-6) }); - const overrides = createOverrideRateTable({ - "claude-haiku-5-5": { inputCostPerMillionTokens: 1, outputCostPerMillionTokens: 5 }, - }); - const long = (model: string) => ({ - model, - speed: "standard" as const, - reportedCostUsd: null, - totals: { - uncachedInputTokens: 500_000, - cachedInputTokens: 0, - cacheCreationTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - }, - }); - - expect(priceUsage(table, long("gpt-6-sol")).costUsd).toBeCloseTo(1, 12); - expect(priceUsage(table, long("claude-haiku-5-5"), overrides).costUsd).toBeCloseTo(0.5, 12); - }); - it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const;