fix(pricing): stop billing reasoning tokens twice (#9212)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-05 22:40:13 -03:00
committed by GitHub
parent 103bab99d5
commit a8fb5dc4e9
6 changed files with 97 additions and 8 deletions

View File

@@ -0,0 +1 @@
- **fix(pricing):** stop billing reasoning tokens twice. (thanks @yidecode)

View File

@@ -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,
};
}

View File

@@ -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,
});
}

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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);