From a8fb5dc4e99a207d40b8b8e382235cb50d7e52f9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 22:40:13 -0300 Subject: [PATCH] fix(pricing): stop billing reasoning tokens twice (#9212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake). --- .../9212-reasoning-cost-double-billing.md | 1 + open-sse/handlers/usageExtractor.ts | 7 +- open-sse/utils/usageTracking.ts | 7 +- src/lib/usage/costCalculator.ts | 6 +- .../reasoning-cost-double-billing.test.ts | 78 +++++++++++++++++++ tests/unit/usage-extractor.test.ts | 6 +- 6 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/9212-reasoning-cost-double-billing.md create mode 100644 tests/unit/reasoning-cost-double-billing.test.ts diff --git a/changelog.d/fixes/9212-reasoning-cost-double-billing.md b/changelog.d/fixes/9212-reasoning-cost-double-billing.md new file mode 100644 index 0000000000..3c16a3ae8b --- /dev/null +++ b/changelog.d/fixes/9212-reasoning-cost-double-billing.md @@ -0,0 +1 @@ +- **fix(pricing):** stop billing reasoning tokens twice. (thanks @yidecode) diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 114ccefa50..2743eec511 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -91,10 +91,13 @@ export function extractUsageFromResponse(responseBody, provider) { // Gemini format if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") { + // Gemini reports thoughts outside candidates. Fold them into completion so + // every provider keeps reasoning as a subset of completion tokens. + const thoughts = responseBody.usageMetadata.thoughtsTokenCount || 0; return { prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, - completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, - reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount, + completion_tokens: (responseBody.usageMetadata.candidatesTokenCount || 0) + thoughts, + reasoning_tokens: thoughts, }; } diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index f0d6990a90..4aae66c0e2 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -429,12 +429,15 @@ export function extractUsage(chunk) { // chunks do not silently drop token usage. const usageMeta = chunk.usageMetadata || chunk.response?.usageMetadata; if (usageMeta && typeof usageMeta === "object") { + // Gemini reports thoughts outside candidates. Fold them into completion so + // every provider keeps reasoning as a subset of completion tokens. + const thoughts = usageMeta.thoughtsTokenCount || 0; return normalizeUsage({ prompt_tokens: usageMeta.promptTokenCount || 0, - completion_tokens: usageMeta.candidatesTokenCount || 0, + completion_tokens: (usageMeta.candidatesTokenCount || 0) + thoughts, total_tokens: usageMeta.totalTokenCount, cached_tokens: usageMeta.cachedContentTokenCount, - reasoning_tokens: usageMeta.thoughtsTokenCount, + reasoning_tokens: thoughts, }); } diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index a8976319ba..08151ac1aa 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -161,8 +161,12 @@ export function computeCostFromPricing( const outputTokens = tokens.output ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0; cost += outputTokens * (outputPrice / 1_000_000); + // completion_tokens is reasoning-inclusive. Reasoning is already billed at + // the output rate above, so a dedicated price contributes only its premium. const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0; - if (reasoningTokens > 0) cost += reasoningTokens * (reasoningPrice / 1_000_000); + if (reasoningTokens > 0 && pricing.reasoning !== undefined && pricing.reasoning !== null) { + cost += reasoningTokens * ((reasoningPrice - outputPrice) / 1_000_000); + } if (cacheCreationTokens > 0) cost += cacheCreationTokens * (cacheCreationPrice / 1_000_000); diff --git a/tests/unit/reasoning-cost-double-billing.test.ts b/tests/unit/reasoning-cost-double-billing.test.ts new file mode 100644 index 0000000000..584c7f696d --- /dev/null +++ b/tests/unit/reasoning-cost-double-billing.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts"; +import { extractUsage } from "../../open-sse/utils/usageTracking.ts"; +import { computeCostFromPricing } from "../../src/lib/usage/costCalculator.ts"; + +test("reasoning tokens are not billed twice when reasoning matches the output price", () => { + const cost = computeCostFromPricing( + { input: 1, output: 10, reasoning: 10 }, + { prompt_tokens: 0, completion_tokens: 1_000, reasoning_tokens: 500 } + ); + + assert.equal(cost, 0.01); +}); + +test("reasoning tokens add only the declared premium above the output price", () => { + const cost = computeCostFromPricing( + { input: 1, output: 10, reasoning: 22 }, + { prompt_tokens: 0, completion_tokens: 1_000, reasoning_tokens: 500 } + ); + + assert.equal(cost, 0.016); +}); + +test("reasoning tokens add no cost when the model declares no reasoning price", () => { + const cost = computeCostFromPricing( + { input: 1, output: 10 }, + { prompt_tokens: 0, completion_tokens: 1_000, reasoning_tokens: 500 } + ); + + assert.equal(cost, 0.01); +}); + +test("streaming Gemini usage includes thoughts in completion tokens", () => { + const usage = extractUsage({ + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 40, + thoughtsTokenCount: 10, + totalTokenCount: 150, + }, + }); + + assert.equal(usage.completion_tokens, 50); + assert.equal(usage.reasoning_tokens, 10); +}); + +test("non-streaming Gemini usage includes thoughts in completion tokens", () => { + const usage = extractUsageFromResponse( + { + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 40, + thoughtsTokenCount: 10, + }, + }, + "gemini" + ); + + assert.equal(usage.completion_tokens, 50); + assert.equal(usage.reasoning_tokens, 10); +}); + +test("Gemini usage normalization preserves candidate and thought pricing", () => { + const usage = extractUsage({ + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 40, + thoughtsTokenCount: 10, + totalTokenCount: 150, + }, + }); + + const cost = computeCostFromPricing({ input: 1, output: 4, reasoning: 10 }, usage); + + const expected = (100 * 1 + 40 * 4 + 10 * 10) / 1_000_000; + assert.ok(Math.abs(cost - expected) < 1e-12); +}); diff --git a/tests/unit/usage-extractor.test.ts b/tests/unit/usage-extractor.test.ts index 84d330e757..44cb70e776 100644 --- a/tests/unit/usage-extractor.test.ts +++ b/tests/unit/usage-extractor.test.ts @@ -194,7 +194,7 @@ test("extractUsageFromResponse reads Gemini usageMetadata and thinking tokens", assert.deepEqual(usage, { prompt_tokens: 11, - completion_tokens: 5, + completion_tokens: 7, reasoning_tokens: 2, }); }); @@ -389,7 +389,7 @@ test("extractUsage reads top-level Gemini usageMetadata from a streaming chunk", }); assert.equal(usage.prompt_tokens, 120); - assert.equal(usage.completion_tokens, 60); + assert.equal(usage.completion_tokens, 72); assert.equal(usage.total_tokens, 180); assert.equal(usage.cached_tokens, 30); assert.equal(usage.reasoning_tokens, 12); @@ -412,7 +412,7 @@ test("extractUsage reads Antigravity usageMetadata wrapped inside a response env assert.notEqual(usage, null); assert.equal(usage.prompt_tokens, 200); - assert.equal(usage.completion_tokens, 75); + assert.equal(usage.completion_tokens, 93); assert.equal(usage.total_tokens, 275); assert.equal(usage.cached_tokens, 40); assert.equal(usage.reasoning_tokens, 18);