Skip to content
216 changes: 216 additions & 0 deletions src/api/providers/__tests__/openai-usage-tracking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,31 @@ describe("OpenAiHandler with usage tracking fix", () => {
})
})

it("should report OpenAI-compatible cached prompt tokens", async () => {
mockCreate.mockImplementationOnce(async () =>
asyncStreamFrom([
{
choices: [{ delta: { content: "Cached response" }, index: 0 }],
usage: {
prompt_tokens: 5_053,
completion_tokens: 16,
total_tokens: 5_069,
prompt_tokens_details: { cached_tokens: 4_864 },
},
},
]),
)

const chunks = await collectStream(handler.createMessage(systemPrompt, messages))

expect(chunks).toContainEqual({
type: "usage",
inputTokens: 5_053,
outputTokens: 16,
cacheReadTokens: 4_864,
})
})

it("should handle case where no usage is provided", async () => {
// Override the mock for this specific test
mockCreate.mockImplementationOnce(async (options) => {
Expand Down Expand Up @@ -212,4 +237,195 @@ describe("OpenAiHandler with usage tracking fix", () => {
expect(usageChunks).toHaveLength(0)
})
})

it.each(["gpt-4", "o3-mini"])(
"reports cached prompt tokens from a non-streaming %s response",
async (openAiModelId) => {
const nonStreamingHandler = new OpenAiHandler({
...mockOptions,
openAiModelId,
openAiStreamingEnabled: false,
})
mockCreate.mockImplementationOnce(async () => ({
id: "test-completion",
choices: [{ message: { role: "assistant", content: "Cached response" } }],
usage: {
prompt_tokens: 4_621,
completion_tokens: 16,
total_tokens: 4_637,
prompt_tokens_details: { cached_tokens: 4_608 },
},
}))

const chunks = await collectStream(nonStreamingHandler.createMessage("system prompt", []))

expect(chunks).toContainEqual({
type: "usage",
inputTokens: 4_621,
outputTokens: 16,
cacheReadTokens: 4_608,
})
},
)

it("reports cached prompt tokens for a streaming O3 response", async () => {
const o3Handler = new OpenAiHandler({ ...mockOptions, openAiModelId: "o3-mini" })
mockCreate.mockImplementationOnce(async () =>
asyncStreamFrom([
{
choices: [{ delta: { content: "Cached response" }, index: 0 }],
usage: {
prompt_tokens: 5_053,
completion_tokens: 16,
total_tokens: 5_069,
prompt_tokens_details: { cached_tokens: 4_864 },
},
},
]),
)

const chunks = await collectStream(o3Handler.createMessage("system prompt", []))

expect(chunks).toContainEqual({
type: "usage",
inputTokens: 5_053,
outputTokens: 16,
cacheReadTokens: 4_864,
})
})

it.each([
["string", "10"],
["object", { tokens: 10 }],
["negative", -1],
["fractional", 10.5],
["non-finite", Number.POSITIVE_INFINITY],
["greater than prompt tokens", 101],
])("ignores invalid %s cached prompt tokens in streaming responses", async (_name, cachedTokens) => {
mockCreate.mockImplementationOnce(async () =>
asyncStreamFrom([
{
choices: [{ delta: { content: "Response" }, index: 0 }],
usage: {
prompt_tokens: 100,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: cachedTokens },
},
},
]),
)

const chunks = await collectStream(handler.createMessage("system prompt", []))

expect(chunks).toContainEqual({ type: "usage", inputTokens: 100, outputTokens: 5 })
})

it.each([
["string", "10"],
["object", { tokens: 10 }],
["negative", -1],
["fractional", 10.5],
["non-finite", Number.POSITIVE_INFINITY],
["greater than prompt tokens", 101],
])("ignores invalid %s cached prompt tokens in non-streaming responses", async (_name, cachedTokens) => {
const nonStreamingHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled: false })
mockCreate.mockImplementationOnce(async () => ({
id: "test-completion",
choices: [{ message: { role: "assistant", content: "Response" } }],
usage: {
prompt_tokens: 100,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: cachedTokens },
},
}))

const chunks = await collectStream(nonStreamingHandler.createMessage("system prompt", []))

expect(chunks).toContainEqual({ type: "usage", inputTokens: 100, outputTokens: 5 })
})

describe.each([true, false])("cache accounting with streaming=%s", (openAiStreamingEnabled) => {
it.each([
["authoritative count", 23, 23],
["authoritative zero", 0, undefined],
["fully cached prompt", 100, 100],
["null falls back", null, 71],
["absent falls back", undefined, 71],
["negative", -1, undefined],
["fractional", 10.5, undefined],
["infinite", Infinity, undefined],
["NaN", NaN, undefined],
["string", "23", undefined],
["object", { tokens: 23 }, undefined],
["proxy cache reads exceed uncached input", 101, 101],
])("respects %s without substituting a conflicting fallback", async (_name, reported, expected) => {
const cacheHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled })
const usage = {
prompt_tokens: 100,
completion_tokens: 5,
cache_creation_input_tokens: 7,
cache_read_input_tokens: reported,
prompt_tokens_details: { cached_tokens: 71 },
}
mockCreate.mockResolvedValueOnce(
openAiStreamingEnabled
? asyncStreamFrom([{ choices: [], usage }])
: { choices: [{ message: { content: "Response" } }], usage },
)

const chunks = await collectStream(cacheHandler.createMessage("system prompt", []))

expect(chunks.filter((chunk) => chunk.type === "usage")).toEqual([
{
type: "usage",
inputTokens: 100,
outputTokens: 5,
cacheWriteTokens: 7,
cacheReadTokens: expected,
},
])
})

// Only OpenAI's nested counter must be a subset of prompt_tokens; this pins the inclusive boundary.
it.each([
["below the prompt size", 99, 99],
["equal to the prompt size", 100, 100],
["above the prompt size", 101, undefined],
])(
"accepts a nested cached_tokens count %s with no cache_read_input_tokens",
async (_name, cached, expected) => {
const cacheHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled })
const usage = {
prompt_tokens: 100,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: cached },
}
mockCreate.mockResolvedValueOnce(
openAiStreamingEnabled
? asyncStreamFrom([{ choices: [], usage }])
: { choices: [{ message: { content: "Response" } }], usage },
)

const chunks = await collectStream(cacheHandler.createMessage("system prompt", []))

expect(chunks.filter((chunk) => chunk.type === "usage")).toEqual([
{ type: "usage", inputTokens: 100, outputTokens: 5, cacheReadTokens: expected },
])
},
)
})

it.each([null, undefined, {}, { prompt_tokens_details: null }])(
"defaults missing non-streaming usage counters to zero: %j",
async (usage) => {
const nonStreamingHandler = new OpenAiHandler({ ...mockOptions, openAiStreamingEnabled: false })
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "Response" } }], usage })

const chunks = await collectStream(nonStreamingHandler.createMessage("system prompt", []))

expect(chunks.filter((chunk) => chunk.type === "usage")).toEqual([
{ type: "usage", inputTokens: 0, outputTokens: 0 },
])
},
)
})
36 changes: 28 additions & 8 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@
import { handleOpenAIError } from "./utils/error-handler"
import { extractReasoningFromDelta } from "./utils/extract-reasoning"

type OpenAiUsage =
| {
prompt_tokens?: number
completion_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: unknown
prompt_tokens_details?: { cached_tokens?: unknown } | null
}
| null
| undefined

// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
// `OpenAINativeHandler` can subclass from this, since it's obviously
// compatible with the OpenAI API. We can also rename it to `OpenAIHandler`.
Expand Down Expand Up @@ -288,13 +299,26 @@
}
}

protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk {
protected processUsageMetrics(usage: OpenAiUsage, _modelInfo?: ModelInfo): ApiStreamUsageChunk {
const inputTokens = usage?.prompt_tokens || 0
const reportedCacheReadTokens = usage?.cache_read_input_tokens ?? usage?.prompt_tokens_details?.cached_tokens
const cacheReadTokens =
typeof reportedCacheReadTokens === "number" &&

Check warning on line 306 in src/api/providers/openai.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openai.ts:306: 3 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
Number.isFinite(reportedCacheReadTokens) &&
Number.isInteger(reportedCacheReadTokens) &&
reportedCacheReadTokens >= 0 &&

Check warning on line 309 in src/api/providers/openai.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openai.ts:309: Survived EqualityOperator mutant (replacement: reportedCacheReadTokens > 0). See the job summary for the complete list and resolution guidance.
// Proxy-specific counters may exclude cache reads from prompt_tokens.
// Only OpenAI's nested cached_tokens must be a subset of prompt_tokens.
(usage?.cache_read_input_tokens != null || reportedCacheReadTokens <= inputTokens)

Check warning on line 312 in src/api/providers/openai.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openai.ts:312: Survived OptionalChaining mutant (replacement: usage.cache_read_input_tokens). See the job summary for the complete list and resolution guidance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a test where the nested prompt_tokens_details.cached_tokens equals prompt_tokens with no cache_read_input_tokens? The "fully cached prompt" row sets cache_read_input_tokens, so it skips this check, and changing <= to < would pass every test. A case like {prompt_tokens: 100, prompt_tokens_details: {cached_tokens: 100}} expecting cacheReadTokens: 100 would pin it.

? reportedCacheReadTokens || undefined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: undefined

return {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,
inputTokens,
outputTokens: usage?.completion_tokens || 0,
cacheWriteTokens: usage?.cache_creation_input_tokens || undefined,
cacheReadTokens: usage?.cache_read_input_tokens || undefined,
cacheReadTokens,
}
}

Expand Down Expand Up @@ -499,11 +523,7 @@
}

if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
yield this.processUsageMetrics(chunk.usage)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@
},
"api/providers/openai.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
"count": 2
}
},
"api/providers/openrouter.ts": {
Expand Down
Loading