fix(usage): preserve nested prompt cache reads before cost calculation (#13760)

* fix(usage): preserve nested prompt cache reads

* docs(changelog): add nested cache-read cost fix

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
小妍儿 ✨
2026-09-18 22:32:56 +08:00
committed by GitHub
parent 57d7e861d1
commit 5de6c5108b
4 changed files with 217 additions and 3 deletions

View File

@@ -0,0 +1 @@
- **fix(usage):** preserve nested prompt cache-read counters before cost calculation ([#13760](https://github.com/diegosouzapw/OmniRoute/pull/13760)) — fixes [#13746](https://github.com/diegosouzapw/OmniRoute/issues/13746)

View File

@@ -479,6 +479,8 @@ function clearCachedTokenDetail<T extends UsageTokenDetail | null | undefined>(v
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const result = { ...value };
if (result.cached_tokens !== undefined) result.cached_tokens = 0;
if (result.cache_creation_tokens !== undefined) result.cache_creation_tokens = 0;
if (result.cache_write_tokens !== undefined) result.cache_write_tokens = 0;
return result;
}
@@ -513,6 +515,8 @@ export function sanitizeProviderUsageForRequest(
if (format === FORMATS.CLAUDE) {
result.input_tokens = estimatedInput;
result.prompt_tokens_details = clearCachedTokenDetail(result.prompt_tokens_details);
result.input_tokens_details = clearCachedTokenDetail(result.input_tokens_details);
result.cache_read_input_tokens = 0;
result.cache_creation_input_tokens = 0;
return result;
@@ -531,6 +535,7 @@ export function sanitizeProviderUsageForRequest(
if (format === FORMATS.OPENAI_RESPONSES) {
result.input_tokens = estimatedInput;
result.prompt_tokens_details = clearCachedTokenDetail(result.prompt_tokens_details);
result.input_tokens_details = clearCachedTokenDetail(result.input_tokens_details);
result.cache_read_input_tokens = 0;
result.cache_creation_input_tokens = 0;
@@ -624,7 +629,12 @@ export function normalizeUsage(usage: UsageLike | null | undefined) {
assignNumber("output_tokens", usage?.output_tokens);
assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens);
assignNumber("cache_creation_input_tokens", pickCacheCreationTokens(usage));
assignNumber("cached_tokens", usage?.cached_tokens);
assignNumber(
"cached_tokens",
usage?.cached_tokens ??
usage?.prompt_tokens_details?.cached_tokens ??
usage?.input_tokens_details?.cached_tokens
);
assignNumber("no_cache_tokens", usage?.no_cache_tokens);
assignNumber("reasoning_tokens", usage?.reasoning_tokens);
// xAI's exact provider-reported cost (port of decolua/9router#2453, capability A —

View File

@@ -0,0 +1,203 @@
/**
* Regression coverage for #13746.
*
* Non-streaming Chat/Responses callers pass provider usage through normalizeUsage()
* before calculateCost(). Nested cache-read counters were dropped at that boundary,
* so the calculator charged the whole prompt at the normal input rate.
*
* Pricing and usage below are synthetic: input=$10/M, cache-read=$1/M,
* output=$50/M; 100,000 input tokens with 99,000 cached tokens must cost $0.109.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cache-read-13746-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { normalizeUsage, sanitizeProviderUsageForRequest } =
await import("../../open-sse/utils/usageTracking.ts");
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts");
const PROVIDER = "synthetic-cache-read-13746";
const MODEL = "synthetic-cache-read-model-13746";
const PRICING = { input: 10, cached: 1, output: 50 };
function seedSyntheticPricing(): void {
core
.getDbInstance()
.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
.run("pricing", PROVIDER, JSON.stringify({ [MODEL]: PRICING }));
}
async function costOf(usage: Record<string, unknown>): Promise<number> {
return calculateCost(PROVIDER, MODEL, normalizeUsage(usage));
}
async function readSse(chunks: string[], options: Record<string, unknown> = {}): Promise<string> {
const source = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
controller.close();
},
});
return new Response(
source.pipeThrough(
createSSEStream({
mode: "translate",
sourceFormat: "openai",
targetFormat: "openai-responses",
clientResponseFormat: "openai-responses",
provider: PROVIDER,
model: MODEL,
...options,
})
)
).text();
}
function assertCost(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < Number.EPSILON, `${actual} !== ${expected}`);
}
test.before(() => {
seedSyntheticPricing();
});
test.after(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("implausible usage repair clears nested cache-read details before normalization", () => {
const claude = sanitizeProviderUsageForRequest(
{
input_tokens: 1_000,
cache_read_input_tokens: 336_000,
prompt_tokens_details: { cached_tokens: 336_000, cache_creation_tokens: 12 },
},
{},
"claude"
);
assert.ok((claude?.input_tokens ?? 0) > 0);
assert.equal(claude?.cache_read_input_tokens, 0);
assert.equal(claude?.prompt_tokens_details?.cached_tokens, 0);
assert.equal(claude?.prompt_tokens_details?.cache_creation_tokens, 0);
const responses = sanitizeProviderUsageForRequest(
{
input_tokens: 336_000,
input_tokens_details: { cached_tokens: 336_000, cache_creation_tokens: 12 },
},
{},
"openai-responses"
);
assert.ok((responses?.input_tokens ?? 0) > 0);
assert.equal(responses?.input_tokens_details?.cached_tokens, 0);
assert.equal(responses?.input_tokens_details?.cache_creation_tokens, 0);
});
test("Responses SSE metadata uses normalized nested cache-read usage", async () => {
const previous = process.env.OMNIROUTE_SSE_COMMENTS;
process.env.OMNIROUTE_SSE_COMMENTS = "on";
try {
const output = await readSse([
`event: response.completed\ndata: ${JSON.stringify({
type: "response.completed",
response: {
id: "resp-cache-read-13746",
output: [{ type: "message", content: [{ type: "output_text", text: "done" }] }],
usage: {
input_tokens: 100_000,
output_tokens: 0,
input_tokens_details: { cached_tokens: 99_000 },
},
},
})}\n\n`,
]);
assert.match(output, /: x-omniroute-response-cost=0\.1090000000/);
} finally {
if (previous === undefined) delete process.env.OMNIROUTE_SSE_COMMENTS;
else process.env.OMNIROUTE_SSE_COMMENTS = previous;
}
});
test("Chat nested prompt_tokens_details.cached_tokens reaches cost calculation", async () => {
const usage = normalizeUsage({
prompt_tokens: 100_000,
completion_tokens: 0,
prompt_tokens_details: { cached_tokens: 99_000 },
});
assert.equal(usage?.cached_tokens, 99_000);
assertCost(await calculateCost(PROVIDER, MODEL, usage), 0.109);
});
test("Responses nested input_tokens_details.cached_tokens reaches cost calculation", async () => {
const usage = normalizeUsage({
input_tokens: 100_000,
output_tokens: 0,
input_tokens_details: { cached_tokens: 99_000 },
});
assert.equal(usage?.cached_tokens, 99_000);
assertCost(await calculateCost(PROVIDER, MODEL, usage), 0.109);
});
test("flat cache-read fields retain their existing cost semantics", async () => {
assertCost(
await costOf({ prompt_tokens: 100_000, cached_tokens: 99_000, completion_tokens: 0 }),
0.109
);
assertCost(
await costOf({ input_tokens: 100_000, cache_read_input_tokens: 99_000, output_tokens: 0 }),
0.109
);
});
test("an explicit zero cache-read count wins over a nested fallback", async () => {
const usage = normalizeUsage({
prompt_tokens: 100_000,
prompt_tokens_details: { cached_tokens: 99_000 },
cached_tokens: 0,
});
assert.equal(usage?.cached_tokens, 0);
assert.equal(await calculateCost(PROVIDER, MODEL, usage), 1);
});
test("missing or non-finite cache-read fields stay omitted", async () => {
const missing = normalizeUsage({ prompt_tokens: 100_000, completion_tokens: 0 });
assert.equal(Object.hasOwn(missing ?? {}, "cached_tokens"), false);
assert.equal(Object.hasOwn(missing ?? {}, "cache_read_input_tokens"), false);
assert.equal(await calculateCost(PROVIDER, MODEL, missing), 1);
const nonFinite = normalizeUsage({
prompt_tokens: 100_000,
input_tokens_details: { cached_tokens: Number.NaN },
});
assert.equal(Object.hasOwn(nonFinite ?? {}, "cached_tokens"), false);
assert.equal(await calculateCost(PROVIDER, MODEL, nonFinite), 1);
});
test("nested cache-creation remains compatible with cache-read normalization", async () => {
const usage = normalizeUsage({
input_tokens: 100_000,
output_tokens: 0,
input_tokens_details: {
cached_tokens: 99_000,
cache_creation_tokens: 5,
},
});
assert.equal(usage?.cached_tokens, 99_000);
assert.equal(usage?.cache_creation_input_tokens, 5);
assertCost(await calculateCost(PROVIDER, MODEL, usage), 0.109);
});

View File

@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
const { extractUsageFromResponse } = await import("../../open-sse/handlers/usageExtractor.ts");
const { extractUsage, normalizeUsage } = await import("../../open-sse/utils/usageTracking.ts");
test("normalizeUsage keeps only finite numeric fields for stream cost calculation", () => {
test("normalizeUsage keeps finite nested cache-read fields for stream cost calculation", () => {
assert.deepEqual(
normalizeUsage({
input_tokens: "12",
@@ -12,7 +12,7 @@ test("normalizeUsage keeps only finite numeric fields for stream cost calculatio
total_tokens: Number.POSITIVE_INFINITY,
input_tokens_details: { cached_tokens: 2 },
}),
{ input_tokens: 12, output_tokens: 3 }
{ input_tokens: 12, output_tokens: 3, cached_tokens: 2 }
);
});