From 2a72c9505c8a9d6af1363a7d7e67bca9bfd732fb Mon Sep 17 00:00:00 2001 From: eeshsaxena Date: Thu, 23 Jul 2026 17:22:23 +0530 Subject: [PATCH] fix(cost): tier Anthropic cache reads by the whole prompt size getThresholdValueFunction returns 0 for cachedInputCost on the anthropic branch, so getPricingTier always resolves to tier 0 and a cache read is priced at the base input rate no matter how large the prompt is. inputCost and outputCost on the same request already use the prompt total. For claude-sonnet-4 a 250K-token prompt with a 240K cache read is billed $0.072 instead of $0.144 - half rate - because the >200K tier doubles the input rate the cache multiplier applies to. vertex, google-ai-studio and xai all include cachedInputCost in their threshold cases, and the Gemini over-threshold test asserts the higher tier for its cache read, so anthropic looks like an oversight rather than intent. Updates the existing over-threshold assertion, which had encoded the old behaviour, and adds a mostly-cached long prompt as a regression test. --- .../cost/modelCostFromRegistry.test.ts | 31 ++++++++++++++++++- packages/cost/models/calculate-cost.ts | 10 ++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/__tests__/cost/modelCostFromRegistry.test.ts b/packages/__tests__/cost/modelCostFromRegistry.test.ts index 73aa4f6602..dfaac9b114 100644 --- a/packages/__tests__/cost/modelCostFromRegistry.test.ts +++ b/packages/__tests__/cost/modelCostFromRegistry.test.ts @@ -317,11 +317,40 @@ describe("modelCostBreakdownFromRegistry", () => { // Higher tier: $6/M input, $22.50/M output (multipliers inherited from base tier) expect(breakdown.inputCost).toBe(250000 * 0.000006); expect(breakdown.outputCost).toBe(50000 * 0.0000225); - expect(breakdown.cachedInputCost).toBe(10000 * 0.000003 * 0.1); + // The cache read belongs to a prompt that is over the threshold, so it + // bills at the same higher tier as the input - matching how the Gemini + // case below prices its cache read. + expect(breakdown.cachedInputCost).toBe(10000 * 0.000006 * 0.1); + // Cache writes still use the base tier: calculateModelCostBreakdown + // prices those from basePricing directly, for every provider. expect(breakdown.cacheWrite5mCost).toBe(5000 * 0.000003 * 1.25); } }); + it("should tier a Claude cache read by the whole prompt size", () => { + // Regression: the cache read was priced from tier 0 regardless of prompt + // size, because the anthropic threshold function returned 0 for + // cachedInputCost. A mostly-cached long prompt was billed at half rate. + const modelUsage: ModelUsage = { + input: 10000, + output: 100, + cacheDetails: { + cachedInput: 240000, // prompt totals 250K, over the 200K threshold + }, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "claude-sonnet-4-20250514", + provider: "anthropic" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + expect(breakdown.cachedInputCost).toBe(240000 * 0.000006 * 0.1); + } + }); + it("should use base tier pricing for Gemini 3 Pro Preview under 200K tokens", () => { const modelUsage: ModelUsage = { input: 150000, // 150K tokens - under threshold diff --git a/packages/cost/models/calculate-cost.ts b/packages/cost/models/calculate-cost.ts index 860396a924..4b102e24a2 100644 --- a/packages/cost/models/calculate-cost.ts +++ b/packages/cost/models/calculate-cost.ts @@ -142,9 +142,13 @@ function getThresholdValueFunction(provider: ModelProviderName): (usage: ModelUs switch (field) { case "inputCost": case "outputCost": - return usage.input + - (usage.cacheDetails?.cachedInput ?? 0) + - (usage.cacheDetails?.write5m ?? 0) + + // Anthropic's long-context tier is chosen by the size of the whole + // prompt, so a cache read on a >threshold request is billed at the + // same higher tier as the input it belongs to. + case "cachedInputCost": + return usage.input + + (usage.cacheDetails?.cachedInput ?? 0) + + (usage.cacheDetails?.write5m ?? 0) + (usage.cacheDetails?.write1h ?? 0); default: return 0;