fix: calculate OpenRouter costs locally to avoid API miscalculation

- Calculate costs using model pricing info instead of relying on OpenRouter API response
- Fixes issue where OpenRouter returns incorrect cost values (e.g., $0.46 instead of $1.50+ for 527k tokens)
- Falls back to API cost if model pricing info is unavailable
- Added test case to verify fix for reported issue #8650
This commit is contained in:
Roo Code 2025-10-14 09:04:31 +00:00
parent 6b8c21f873
commit 1eecf1fb43
2 changed files with 80 additions and 2 deletions

View file

@ -157,7 +157,15 @@ describe("OpenRouterHandler", () => {
// Verify stream chunks
expect(chunks).toHaveLength(2) // One text chunk and one usage chunk
expect(chunks[0]).toEqual({ type: "text", text: "test response" })
expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20, totalCost: 0.001 })
// Cost is now calculated locally: (3/1M * 10) + (15/1M * 20) = 0.00003 + 0.0003 = 0.00033
expect(chunks[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: expect.closeTo(0.00033, 5),
})
// Verify OpenAI client was called with correct parameters.
expect(mockCreate).toHaveBeenCalledWith(
@ -267,6 +275,57 @@ describe("OpenRouterHandler", () => {
const generator = handler.createMessage("test", [])
await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error")
})
it("calculates cost locally when OpenRouter API returns incorrect cost (issue #8650)", async () => {
const handler = new OpenRouterHandler({
...mockOptions,
openRouterModelId: "anthropic/claude-3.5-sonnet", // Use Claude 3.5 Sonnet as in the issue
})
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [{ delta: { content: "test" } }],
}
// Simulate the issue: OpenRouter returns incorrect cost ($0.46) for 527k input tokens
// Actual cost should be: (527000 * 3 / 1M) + (7700 * 15 / 1M) = 1.581 + 0.1155 = 1.6965
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: {
prompt_tokens: 527000,
completion_tokens: 7700,
cost: 0.46, // OpenRouter's incorrect cost value
},
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
// Verify that we calculate the correct cost locally
// Model pricing: inputPrice: 3, outputPrice: 15
// Cost = (527000 / 1M * 3) + (7700 / 1M * 15) = 1.581 + 0.1155 = 1.6965
expect(chunks[1]).toEqual({
type: "usage",
inputTokens: 527000,
outputTokens: 7700,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: expect.closeTo(1.6965, 5),
})
})
})
describe("completePrompt", () => {

View file

@ -10,6 +10,7 @@ import {
} from "@roo-code/types"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStreamChunk } from "../transform/stream"
@ -196,13 +197,31 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
}
if (lastUsage) {
// Get model info to calculate cost locally
const modelInfo = this.getModel().info
// Calculate cost locally using model pricing information
// OpenRouter uses OpenAI-style token counting (input tokens include cached tokens)
const localCost = calculateApiCostOpenAI(
modelInfo,
lastUsage.prompt_tokens || 0,
lastUsage.completion_tokens || 0,
undefined, // cache creation tokens - OpenRouter doesn't distinguish this
lastUsage.prompt_tokens_details?.cached_tokens,
)
// Use locally calculated cost, but fall back to API response if our calculation fails
// or if model pricing info is not available
const apiCost = (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0)
const totalCost = modelInfo.inputPrice && modelInfo.outputPrice ? localCost : apiCost
yield {
type: "usage",
inputTokens: lastUsage.prompt_tokens || 0,
outputTokens: lastUsage.completion_tokens || 0,
cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens,
reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens,
totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0),
totalCost,
}
}
}