From ac078b41eacfe1465caa99d26dc4fe9d8b930d18 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 11 Jun 2026 08:29:57 -0700 Subject: [PATCH] fix(api-proxy): stop double-counting cached tokens in AI credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Anthropic and OpenAI report input_tokens as the TOTAL input including cache_read and cache_creation tokens. The AI credits calculation was charging the full input_tokens at the full input rate, then ALSO charging cache_read_tokens at the cache rate — effectively double-counting cached tokens. Example (claude-sonnet-4-6, 3M input, 2.9M cached): Before: 3M × $3/Mtok + 2.9M × $0.30/Mtok ≈ 1017 AIC After: 0.1M × $3/Mtok + 2.9M × $0.30/Mtok ≈ 192 AIC (correct) The fix subtracts cache_read_tokens and cache_write_tokens from input_tokens before applying the full input rate, since those portions are already accounted for at their respective discounted rates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-proxy/guards/ai-credits-guard.js | 14 ++++-- .../api-proxy/guards/ai-credits-guard.test.js | 49 ++++++++++++++++--- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/containers/api-proxy/guards/ai-credits-guard.js b/containers/api-proxy/guards/ai-credits-guard.js index 02a68d23d..0f99c6059 100644 --- a/containers/api-proxy/guards/ai-credits-guard.js +++ b/containers/api-proxy/guards/ai-credits-guard.js @@ -153,10 +153,18 @@ function calculateAiCredits(normalizedUsage, model, state = aiCreditsState) { const pricing = resolveModelPricing(model, state); if (!pricing) return null; - const inputCredits = ((normalizedUsage.input_tokens || 0) * pricing.input) / CREDIT_DENOMINATOR; - const cachedInputCredits = ((normalizedUsage.cache_read_tokens || 0) * pricing.cachedInput) / CREDIT_DENOMINATOR; + // Both Anthropic and OpenAI report input_tokens as the TOTAL input including + // cache_read and cache_creation tokens. To avoid double-counting, subtract + // cached portions before applying the full input rate. + const totalInput = normalizedUsage.input_tokens || 0; + const cacheReadTokens = normalizedUsage.cache_read_tokens || 0; + const cacheWriteTokens = normalizedUsage.cache_write_tokens || 0; + const nonCachedInput = Math.max(0, totalInput - cacheReadTokens - cacheWriteTokens); + + const inputCredits = (nonCachedInput * pricing.input) / CREDIT_DENOMINATOR; + const cachedInputCredits = (cacheReadTokens * pricing.cachedInput) / CREDIT_DENOMINATOR; const cacheWriteCredits = pricing.cacheWrite - ? ((normalizedUsage.cache_write_tokens || 0) * pricing.cacheWrite) / CREDIT_DENOMINATOR + ? (cacheWriteTokens * pricing.cacheWrite) / CREDIT_DENOMINATOR : 0; const outputCredits = ((normalizedUsage.output_tokens || 0) * pricing.output) / CREDIT_DENOMINATOR; const totalCredits = inputCredits + cachedInputCredits + cacheWriteCredits + outputCredits; diff --git a/containers/api-proxy/guards/ai-credits-guard.test.js b/containers/api-proxy/guards/ai-credits-guard.test.js index 9fc64f555..7290ebf50 100644 --- a/containers/api-proxy/guards/ai-credits-guard.test.js +++ b/containers/api-proxy/guards/ai-credits-guard.test.js @@ -44,20 +44,24 @@ describe('ai-credits-guard', () => { output_tokens: 500, }, 'gpt-5-mini'); + // input_tokens includes cache_read_tokens, so non-cached = 900 + // inputCredits = 900 × $0.25/Mtok / 10000 = 0.0225 + // cachedInputCredits = 100 × $0.025/Mtok / 10000 = 0.00025 + // outputCredits = 500 × $2.00/Mtok / 10000 = 0.1 expect(usage).toMatchObject({ - aiCreditsThisResponse: 0.12525, - totalAiCredits: 0.12525, + aiCreditsThisResponse: 0.12275, + totalAiCredits: 0.12275, }); - expect(process.env.AWF_AI_CREDITS_USED).toBe('0.12525'); + expect(process.env.AWF_AI_CREDITS_USED).toBe('0.12275'); expect(getAiCreditsReflectState()).toEqual({ - total: 0.12525, + total: 0.12275, by_model: { 'gpt-5-mini': { - input_credits: 0.025, + input_credits: 0.0225, cached_input_credits: 0.00025, cache_write_credits: 0, output_credits: 0.1, - total: 0.12525, + total: 0.12275, }, }, }); @@ -71,8 +75,37 @@ describe('ai-credits-guard', () => { output_tokens: 100, }, 'claude-sonnet-4-6-20260601'); - expect(usage.aiCreditsThisResponse).toBeCloseTo(0.9675, 10); - expect(getAiCreditsReflectState().by_model['claude-sonnet-4-6-20260601'].total).toBeCloseTo(0.9675, 10); + // nonCached = 2000 - 1000 - 500 = 500 + // inputCredits = 500 × $3.00 / 10000 = 0.15 + // cachedInputCredits = 1000 × $0.30 / 10000 = 0.03 + // cacheWriteCredits = 500 × $3.75 / 10000 = 0.1875 + // outputCredits = 100 × $15.00 / 10000 = 0.15 + expect(usage.aiCreditsThisResponse).toBeCloseTo(0.5175, 10); + expect(getAiCreditsReflectState().by_model['claude-sonnet-4-6-20260601'].total).toBeCloseTo(0.5175, 10); + }); + + it('does not double-count cached tokens (cache_read included in input_tokens)', () => { + // Simulates: 3M total input, 2.9M from cache, 0.1M new input + // This is how Anthropic reports: input_tokens is the total (includes cache hits) + const usage = applyAiCreditsUsage({ + input_tokens: 3_000_000, + cache_read_tokens: 2_900_000, + output_tokens: 50_000, + }, 'claude-sonnet-4-6'); + + // nonCached = 3M - 2.9M = 100K + // inputCredits = 100_000 × $3.00 / 10000 = 30 + // cachedInputCredits = 2_900_000 × $0.30 / 10000 = 87 + // outputCredits = 50_000 × $15.00 / 10000 = 75 + // total = 192 AIC + expect(usage.inputCreditsThisResponse).toBeCloseTo(30, 5); + expect(usage.cachedInputCreditsThisResponse).toBeCloseTo(87, 5); + expect(usage.outputCreditsThisResponse).toBeCloseTo(75, 5); + expect(usage.aiCreditsThisResponse).toBeCloseTo(192, 5); + + // BUG (before fix): would have been 30 + 87 + 75 + (2.9M × $3 / 10000) = 192 + 870 = 1062 + // i.e., cached tokens counted at full price AND cache rate + expect(usage.aiCreditsThisResponse).toBeLessThan(250); }); it('warns and skips usage for unknown models', () => {