feat(ui): add cache hit rate to context sidebar last-message token breakdown (#1524)

* feat(ui): add cache hit rate to context sidebar with verified formula

Add a Cache Hit row to the last-assistant-message token breakdown in
the context sidebar. The percentage is computed by the new
computeCacheHitRate utility:

  cache.read / (input + cache.read + cache.write) x 100

The formula was verified against the SDK source
(packages/opencode/src/session/session.ts:getUsage), which reports
input as the non-cached portion only
(totalInputTokens - cacheReadInputTokens - cacheWriteInputTokens).

Also export sumTokenBreakdown from tokenUtils for reuse, and fix the
event-reducer test type errors (setDelta/getText helpers, toBeCloseTo
replacement).

Closes: #

* fix: wire i18n key for Cache Hit label, fix formatNumber type, drop unintended event-reducer changes

* chore: remove unused cacheHitRate and cacheHitRateTooltip i18n keys from en.ts

* fix: correct cache hit token display

* fix: add French cache hit label

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
raz123
2026-06-11 21:43:41 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 284d72bef2
commit e20cfa2dbc
12 changed files with 179 additions and 9 deletions
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test"
import { computeCacheHitRate, sumTokenBreakdown } from "./tokenUtils"
describe("computeCacheHitRate", () => {
test("returns zero and hasInput=false for null input", () => {
const result = computeCacheHitRate(null)
expect(result).toEqual({ percent: 0, hasInput: false })
})
test("returns zero and hasInput=false for undefined input", () => {
const result = computeCacheHitRate(undefined)
expect(result).toEqual({ percent: 0, hasInput: false })
})
test("returns zero and hasInput=false when input is zero", () => {
const result = computeCacheHitRate({ input: 0, cache: { read: 0, write: 0 } })
expect(result).toEqual({ percent: 0, hasInput: false })
})
test("returns zero and hasInput=false when input is negative", () => {
const result = computeCacheHitRate({ input: -5, cache: { read: 0, write: 0 } })
expect(result).toEqual({ percent: 0, hasInput: false })
})
test("returns zero percent when no cache read tokens", () => {
const result = computeCacheHitRate({ input: 1000, cache: { read: 0, write: 200 } })
expect(result).toEqual({ percent: 0, hasInput: true })
})
test("computes correct percentage: 31.25% with cache read + cache write", () => {
// total = 1000 + 500 + 100 = 1600, hit = 500 / 1600 = 31.25%
const result = computeCacheHitRate({ input: 1000, cache: { read: 500, write: 100 } })
expect(Math.abs(result.percent - 31.25) < 1e-2).toBe(true)
expect(result.hasInput).toBe(true)
})
test("computes correct percentage: 50% when cache read equals non-cached input (no cache write)", () => {
// total = 1000 + 1000 + 0 = 2000, hit = 1000 / 2000 = 50%
const result = computeCacheHitRate({ input: 1000, cache: { read: 1000, write: 0 } })
expect(result.percent).toBe(50)
expect(result.hasInput).toBe(true)
})
test("handles missing cache object", () => {
const result = computeCacheHitRate({ input: 500 })
expect(result).toEqual({ percent: 0, hasInput: true })
})
test("handles missing cache.read", () => {
const result = computeCacheHitRate({ input: 500, cache: { write: 100 } })
expect(result).toEqual({ percent: 0, hasInput: true })
})
test("computes below 100% when cache.read is larger than non-cached input", () => {
// total = 200 + 100 = 300, hit = 200 / 300 = 66.7% — not clamped
const result = computeCacheHitRate({ input: 100, cache: { read: 200, write: 0 } })
expect(Math.abs(result.percent - 66.67) < 1e-2).toBe(true)
expect(result.hasInput).toBe(true)
})
test("clamps to 0% when cache.read is negative (defensive against bad data)", () => {
const result = computeCacheHitRate({ input: 100, cache: { read: -50, write: 0 } })
expect(result.percent).toBe(0)
expect(result.hasInput).toBe(true)
})
test("handles real-world Anthropic example: 850 cached + 100 write + 1000 non-cached", () => {
// total = 1000 + 850 + 100 = 1950, hit = 850 / 1950 ≈ 43.6%
const result = computeCacheHitRate({ input: 1000, cache: { read: 850, write: 100 } })
expect(Math.abs(result.percent - 43.59) < 1e-1).toBe(true)
expect(result.hasInput).toBe(true)
})
test("handles real-world Anthropic example: zero cache on first turn", () => {
// First turn always has 0 cache — should show 0% with hasInput=true
const result = computeCacheHitRate({ input: 2000, cache: { read: 0, write: 2000 } })
expect(result.percent).toBe(0)
expect(result.hasInput).toBe(true)
})
})
describe("sumTokenBreakdown (regression)", () => {
test("sums all fields", () => {
const total = sumTokenBreakdown({
input: 100,
output: 50,
reasoning: 20,
cache: { read: 80, write: 20 },
})
expect(total).toBe(270)
})
test("handles null safely", () => {
expect(sumTokenBreakdown(null)).toBe(0)
expect(sumTokenBreakdown(undefined)).toBe(0)
})
})
+44 -1
View File
@@ -10,7 +10,7 @@ type TokenBreakdown = {
};
};
const sumTokenBreakdown = (breakdown: TokenBreakdown | null | undefined): number => {
export const sumTokenBreakdown = (breakdown: TokenBreakdown | null | undefined): number => {
if (!breakdown || typeof breakdown !== 'object') {
return 0;
}
@@ -49,3 +49,46 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[]
return sumTokenBreakdown(tokenPart.tokens);
};
type CacheHitRateResult = {
/** Cache hit rate as a 0-100 percentage. 0 when there is no input to compare against. */
percent: number;
/** True iff `breakdown` had a positive inclusive input total. When false, `percent` is meaningless. */
hasInput: boolean;
};
/**
* Compute prefix-cache hit rate from a token breakdown.
*
* The SDK reports `input` as the non-cached portion (total input minus
* cache reads and cache writes). The full input processed by the model is
* therefore:
*
* totalInput = input + cache.read + cache.write
*
* cacheHitRate = cache.read / totalInput
*
* Verified against the SDK source (`session.ts:getUsage`): `input`
* is `safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)`.
*
* Returns `hasInput: false` when there is no total input to compare against,
* in which case `percent` is 0 and callers should hide the display.
*/
export const computeCacheHitRate = (breakdown: TokenBreakdown | null | undefined): CacheHitRateResult => {
if (!breakdown || typeof breakdown !== 'object') {
return { percent: 0, hasInput: false };
}
const input = breakdown.input ?? 0;
const cacheRead = breakdown.cache && typeof breakdown.cache === 'object' ? breakdown.cache.read ?? 0 : 0;
const cacheWrite = breakdown.cache && typeof breakdown.cache === 'object' ? breakdown.cache.write ?? 0 : 0;
const total = input + cacheRead + cacheWrite;
if (total <= 0) {
return { percent: 0, hasInput: false };
}
const safeRead = Math.max(0, cacheRead);
const percent = Math.min(100, Math.max(0, (safeRead / total) * 100));
return { percent, hasInput: true };
};