From e9b5e10cc263966ca74086b599e82280444a70bb Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 14 Sep 2026 14:46:37 +0900 Subject: [PATCH] fix(kiro): keep unreported cache counters unknown instead of zero (#4546) Kiro coerced an absent cacheReadInputTokens or cacheWriteInputTokens to 0 and then recorded it as a measured value. Every other usage path omits what it has no reading for, and cacheHitRate is null when unobserved, so this was the one place a silent provider looked like a total cache miss - the exact signal needed to tell whether a routing change preserved the prompt cache. Absence is now unknown; a malformed counter is still a malformed event, and the bridge wire still emits its zero default for strict clients. --- src/adapters/kiro-events.ts | 31 ++++++++++++++++++----- tests/providers/kiro/kiro-stream.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index eab61ea511..3662d8caaa 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -66,6 +66,24 @@ function tokenCount(eventType: string, obj: Record, key: string return value; } +/** + * A cache counter Kiro did not report, kept as unknown rather than zero (#4546). + * + * `OcxUsage` omits cache fields it has no reading for, and `cacheHitRate` is null when + * unobserved -- the convention everywhere except here. Coercing an absent counter to 0 makes + * "the provider said nothing" indistinguishable from "nothing was cached", which is the + * difference between a routing change that preserved the prompt cache and one that destroyed + * it. A malformed value is still a malformed event; only absence is unknown. + */ +function optionalTokenCount( + eventType: string, + obj: Record, + key: string, +): number | undefined { + if (obj[key] === undefined) return undefined; + return tokenCount(eventType, obj, key, true); +} + function parseTokenUsage(eventType: string, value: unknown): OcxUsage | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== "object" || Array.isArray(value)) { @@ -73,19 +91,20 @@ function parseTokenUsage(eventType: string, value: unknown): OcxUsage | undefine } const usage = value as Record; const uncached = tokenCount(eventType, usage, "uncachedInputTokens", true); - const cacheRead = tokenCount(eventType, usage, "cacheReadInputTokens", false); - const cacheWrite = tokenCount(eventType, usage, "cacheWriteInputTokens", false); + const cacheRead = optionalTokenCount(eventType, usage, "cacheReadInputTokens"); + const cacheWrite = optionalTokenCount(eventType, usage, "cacheWriteInputTokens"); const outputTokens = tokenCount(eventType, usage, "outputTokens", true); const totalTokens = tokenCount(eventType, usage, "totalTokens", true); - const inputTokens = uncached + cacheRead + cacheWrite; + // An unreported counter contributes nothing to the total, which is a different statement + // from claiming it was measured as zero. + const inputTokens = uncached + (cacheRead ?? 0) + (cacheWrite ?? 0); if (!Number.isSafeInteger(inputTokens)) return malformed(eventType, "input token usage overflowed"); return { inputTokens, outputTokens, totalTokens, - cachedInputTokens: cacheRead, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheWrite, + ...(cacheRead !== undefined ? { cachedInputTokens: cacheRead, cacheReadInputTokens: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheCreationInputTokens: cacheWrite } : {}), }; } diff --git a/tests/providers/kiro/kiro-stream.test.ts b/tests/providers/kiro/kiro-stream.test.ts index 47dfaf1833..336f264d02 100644 --- a/tests/providers/kiro/kiro-stream.test.ts +++ b/tests/providers/kiro/kiro-stream.test.ts @@ -1548,6 +1548,38 @@ describe("kiro adapter — parseStream", () => { expect(contextTotalTokens).toBeGreaterThan(19); }); + test("unreported cache counters stay unknown instead of being recorded as measured zeros", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(700) }])); + const done = await doneUsage( + adapter, + eventFrame({ content: "answer" }), + eventFrame({ + tokenUsage: { + uncachedInputTokens: 10, + outputTokens: 4, + totalTokens: 14, + }, + }, "metadataEvent"), + ); + // Kiro said nothing about caching on this turn. Storing 0 would make that indistinguishable + // from a measured total miss, which is the difference between routing that preserved a + // prompt cache and routing that destroyed it (#4546). + expect("cachedInputTokens" in done).toBe(false); + expect("cacheReadInputTokens" in done).toBe(false); + expect("cacheCreationInputTokens" in done).toBe(false); + expect(done.inputTokens).toBe(10); + }); + + test("a malformed cache counter is still a malformed event", async () => { + expect(() => parseKiroEvent( + "metadataEvent", + new TextEncoder().encode(JSON.stringify({ + tokenUsage: { uncachedInputTokens: 10, cacheReadInputTokens: -1, outputTokens: 4, totalTokens: 14 }, + })), + )).toThrow(); + }); + test("authoritative turn usage floors a smaller payload context estimate", async () => { const adapter = createKiroAdapter(provider); await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }]));