Skip to content

Commit 69c665a

Browse files
author
Zoo (VP)
committed
fix(dashboard): correct cost calculation for Vertex Gemini models
Vertex AI serves both Claude and Gemini models with different token reporting semantics. Previously, all Vertex models were classified as Anthropic-semantic (inputTokens excludes cached tokens), but Gemini models on Vertex use OpenAI semantics (inputTokens includes cached tokens). This caused double-billing of cached input tokens for Gemini models on Vertex (~7x cost inflation). Replace static ANTHROPIC_SEMANTIC_PROVIDERS set lookup with a dynamic isAnthropicSemantic(provider, model) function that correctly identifies Gemini models on Vertex as OpenAI-semantic.
1 parent 5e7236e commit 69c665a

2 files changed

Lines changed: 97 additions & 5 deletions

File tree

‎src/services/stats/__tests__/costRecalculation.spec.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
computeCacheDiscountBase,
1515
applyCacheDiscount,
1616
customPricingKey,
17+
computeCostFromAggregated,
1718
} from "../costRecalculation"
1819

1920
// ── Test Helpers ────────────────────────────────────────────────────────────
@@ -261,6 +262,45 @@ describe("costRecalculation", () => {
261262
expect(cost).toBeGreaterThan(0)
262263
expect(cost).toBeCloseTo(1.8, 5)
263264
})
265+
266+
it("should use OpenAI semantics for Vertex Gemini models (inputTokens includes cached tokens)", () => {
267+
// Vertex Gemini uses OpenAI semantics: inputTokens includes cacheReadTokens and cacheWriteTokens.
268+
// gemini-3.7-flash: inputPrice=$0.75/1M, outputPrice=$3.75/1M, cacheReadsPrice=$0.075/1M, cacheWritesPrice=$0.5/1M
269+
const event = makeEvent({
270+
provider: "vertex",
271+
model: "gemini-3.7-flash",
272+
usage: {
273+
inputTokens: { value: 1_000_000, source: "provider" },
274+
outputTokens: { value: 100_000, source: "provider" },
275+
cacheWriteTokens: { value: 100_000, source: "provider" },
276+
cacheReadTokens: { value: 200_000, source: "provider" },
277+
},
278+
})
279+
// nonCachedInputTokens = 1,000,000 - 200,000 - 100,000 = 700,000
280+
// cost = 0.7M * $0.75 + 0.1M * $0.5 + 0.2M * $0.075 + 0.1M * $3.75
281+
// = 0.525 + 0.05 + 0.015 + 0.375 = 0.965
282+
const cost = computeEventCost(event)
283+
expect(cost).toBeCloseTo(0.965, 5)
284+
})
285+
286+
it("should use Anthropic semantics for Vertex Claude models (inputTokens excludes cached tokens)", () => {
287+
// Vertex Claude uses Anthropic semantics: inputTokens does NOT include cached tokens.
288+
// claude-sonnet-4-5@20250929: inputPrice=$3.0/1M, outputPrice=$15.0/1M, cacheReadsPrice=$0.30/1M, cacheWritesPrice=$3.75/1M
289+
const event = makeEvent({
290+
provider: "vertex",
291+
model: "claude-sonnet-4-5@20250929",
292+
usage: {
293+
inputTokens: { value: 100_000, source: "provider" },
294+
outputTokens: { value: 10_000, source: "provider" },
295+
cacheWriteTokens: { value: 10_000, source: "provider" },
296+
cacheReadTokens: { value: 20_000, source: "provider" },
297+
},
298+
})
299+
// cost = 0.1M * $3.0 + 0.01M * $3.75 + 0.02M * $0.30 + 0.01M * $15.0
300+
// = 0.30 + 0.0375 + 0.006 + 0.15 = 0.4935
301+
const cost = computeEventCost(event)
302+
expect(cost).toBeCloseTo(0.4935, 5)
303+
})
264304
})
265305

266306
describe("getEffectiveCost", () => {
@@ -748,4 +788,40 @@ describe("costRecalculation", () => {
748788
expect(discounted).toBeCloseTo(1.65, 10)
749789
})
750790
})
791+
792+
describe("computeCostFromAggregated", () => {
793+
it("should use OpenAI semantics for Vertex Gemini models", () => {
794+
// gemini-3.7-flash: inputPrice=$0.75/1M, outputPrice=$3.75/1M, cacheReadsPrice=$0.075/1M, cacheWritesPrice=$0.5/1M
795+
// inputTokens = 1,000,000, outputTokens = 100,000, cacheWriteTokens = 100,000, cacheReadTokens = 200,000
796+
// nonCachedInputTokens = 1,000,000 - 200,000 - 100,000 = 700,000
797+
// cost = 0.7M * $0.75 + 0.1M * $0.5 + 0.2M * $0.075 + 0.1M * $3.75 = 0.965
798+
const cost = computeCostFromAggregated("vertex", "gemini-3.7-flash", 1_000_000, 100_000, 100_000, 200_000)
799+
expect(cost).toBeCloseTo(0.965, 5)
800+
})
801+
802+
it("should use Anthropic semantics for Vertex Claude models", () => {
803+
// claude-sonnet-4-5@20250929: inputPrice=$3.0/1M, outputPrice=$15.0/1M, cacheReadsPrice=$0.30/1M, cacheWritesPrice=$3.75/1M
804+
// inputTokens = 100,000, outputTokens = 10,000, cacheWriteTokens = 10,000, cacheReadTokens = 20,000
805+
// cost = 0.1M * $3.0 + 0.01M * $3.75 + 0.02M * $0.30 + 0.01M * $15.0 = 0.4935
806+
const cost = computeCostFromAggregated(
807+
"vertex",
808+
"claude-sonnet-4-5@20250929",
809+
100_000,
810+
10_000,
811+
10_000,
812+
20_000,
813+
)
814+
expect(cost).toBeCloseTo(0.4935, 5)
815+
})
816+
817+
it("should return 0 when all token counts are zero", () => {
818+
const cost = computeCostFromAggregated("vertex", "gemini-3.7-flash", 0, 0, 0, 0)
819+
expect(cost).toBe(0)
820+
})
821+
822+
it("should return 0 for unknown provider", () => {
823+
const cost = computeCostFromAggregated("unknown-provider", "unknown-model", 1000, 1000, 0, 0)
824+
expect(cost).toBe(0)
825+
})
826+
})
751827
})

‎src/services/stats/costRecalculation.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,26 @@ const PROVIDER_MODEL_REGISTRIES: Record<string, Record<string, ModelInfo>> = {
169169
* `inputTokens` does NOT include cached tokens (cache reads + cache writes
170170
* are reported separately and added to the total).
171171
*
172+
* For providers that serve multiple model families (e.g. Vertex AI serves
173+
* both Claude and Gemini), the semantics depend on the specific model.
174+
* Gemini models on Vertex use OpenAI semantics (inputTokens includes
175+
* cached tokens), while Claude models use Anthropic semantics.
176+
*
172177
* All other providers follow the OpenAI convention where `inputTokens`
173178
* already includes cached tokens.
174179
*/
175-
const ANTHROPIC_SEMANTIC_PROVIDERS = new Set(["anthropic", "bedrock", "vertex"])
180+
const ANTHROPIC_SEMANTIC_PROVIDERS = new Set(["anthropic", "bedrock"])
181+
182+
function isAnthropicSemantic(provider: string, model: string): boolean {
183+
if (ANTHROPIC_SEMANTIC_PROVIDERS.has(provider)) return true
184+
if (provider === "vertex") {
185+
// Vertex AI serves both Gemini and Claude models.
186+
// Gemini models use OpenAI semantics (inputTokens includes cached tokens).
187+
// Claude models use Anthropic semantics (inputTokens excludes cached tokens).
188+
return !model.toLowerCase().startsWith("gemini")
189+
}
190+
return false
191+
}
176192

177193
// ── Model Info Lookup ────────────────────────────────────────────────────────
178194

@@ -284,10 +300,10 @@ export function computeEventCost(event: UsageEventV1, customPricing?: CustomMode
284300
return 0
285301
}
286302

287-
const isAnthropicSemantic = ANTHROPIC_SEMANTIC_PROVIDERS.has(event.provider)
303+
const useAnthropicSemantic = isAnthropicSemantic(event.provider, event.model)
288304

289305
let result
290-
if (isAnthropicSemantic) {
306+
if (useAnthropicSemantic) {
291307
result = calculateApiCostAnthropic(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
292308
} else {
293309
result = calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
@@ -447,10 +463,10 @@ export function computeCostFromAggregated(
447463
return 0
448464
}
449465

450-
const isAnthropicSemantic = ANTHROPIC_SEMANTIC_PROVIDERS.has(provider)
466+
const useAnthropicSemantic = isAnthropicSemantic(provider, model)
451467

452468
let result
453-
if (isAnthropicSemantic) {
469+
if (useAnthropicSemantic) {
454470
result = calculateApiCostAnthropic(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
455471
} else {
456472
result = calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)

0 commit comments

Comments
 (0)