From e20cfa2dbc2c4a984711e2d2ef6c4ce37cc34e1c Mon Sep 17 00:00:00 2001 From: raz123 Date: Thu, 11 Jun 2026 14:43:41 -0400 Subject: [PATCH] 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 --- .../components/layout/ContextSidebarTab.tsx | 37 +++++-- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + .../ui/src/stores/utils/tokenUtils.test.ts | 97 +++++++++++++++++++ packages/ui/src/stores/utils/tokenUtils.ts | 45 ++++++++- 12 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/stores/utils/tokenUtils.test.ts diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index f8e43545..f15e8467 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -8,6 +8,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { computeCacheHitRate } from '@/stores/utils/tokenUtils'; import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; import { copyTextToClipboard } from '@/lib/clipboard'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; @@ -340,6 +341,14 @@ export const ContextPanelContent: React.FC = () => { const tokenBreakdown = contextMessage ? extractTokenBreakdown(contextMessage) : EMPTY_BREAKDOWN; + // Cache hit rate for the last assistant message. `input` is the non-cached portion + // (total input - cache.read - cache.write per SDK's session.ts:getUsage), + // so hit rate = cache.read / (input + cache.read + cache.write). + const cacheHitRate = computeCacheHitRate({ + input: tokenBreakdown.input, + cache: { read: tokenBreakdown.cacheRead, write: tokenBreakdown.cacheWrite }, + }); + const totalAssistantCost = assistantMessages.reduce((sum, message) => { const cost = toNonNegativeNumber((message.info as { cost?: unknown }).cost); return sum + cost; @@ -384,6 +393,7 @@ export const ContextPanelContent: React.FC = () => { providerModel, tokenBreakdown, usagePercent, + cacheHitRate, totalAssistantCost, contextLimit, breakdown: { @@ -471,18 +481,29 @@ export const ContextPanelContent: React.FC = () => { {/* ── Last turn tokens ── */}
-
{t('contextSidebar.section.lastAssistantMessage')}
-
+
{t('contextSidebar.section.lastAssistantMessage')}
+
{([ - { label: t('contextSidebar.tokens.input'), value: viewModel.tokenBreakdown.input }, - { label: t('contextSidebar.tokens.output'), value: viewModel.tokenBreakdown.output }, - { label: t('contextSidebar.tokens.reasoning'), value: viewModel.tokenBreakdown.reasoning }, - { label: t('contextSidebar.tokens.cacheRead'), value: viewModel.tokenBreakdown.cacheRead }, - { label: t('contextSidebar.tokens.cacheWrite'), value: viewModel.tokenBreakdown.cacheWrite }, + { label: t('contextSidebar.tokens.input'), value: viewModel.tokenBreakdown.input, format: 'count' }, + { label: t('contextSidebar.tokens.output'), value: viewModel.tokenBreakdown.output, format: 'count' }, + { label: t('contextSidebar.tokens.reasoning'), value: viewModel.tokenBreakdown.reasoning, format: 'count' }, + { label: t('contextSidebar.tokens.cacheRead'), value: viewModel.tokenBreakdown.cacheRead, format: 'count' }, + { label: t('contextSidebar.tokens.cacheWrite'), value: viewModel.tokenBreakdown.cacheWrite, format: 'count' }, + { + label: t('contextSidebar.tokens.cacheHit'), + value: viewModel.cacheHitRate.hasInput ? viewModel.cacheHitRate.percent : null, + format: 'percent', + }, ] as const).map((item) => (
{item.label}
-
{formatNumber(item.value)}
+
+ {item.value !== null && item.value !== undefined + ? item.format === 'percent' + ? `${item.value.toFixed(1)}%` + : formatNumber(item.value) + : '—'} +
))}
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 6e7e0c5b..a1dd5582 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1158,6 +1158,7 @@ export const dict = { 'contextSidebar.tokens.reasoning': 'Reasoning', 'contextSidebar.tokens.cacheRead': 'Cache Read', 'contextSidebar.tokens.cacheWrite': 'Cache Write', + 'contextSidebar.tokens.cacheHit': 'Cache Hit', 'contextSidebar.actions.copyJson': 'Copy JSON', 'contextSidebar.actions.copy': 'Copy', 'contextSidebar.actions.copied': 'Copied', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 03a42d09..764db8dc 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1124,6 +1124,7 @@ export const dict: Record = { "contextSidebar.tokens.reasoning": "Razonamiento", "contextSidebar.tokens.cacheRead": "Lectura de caché", "contextSidebar.tokens.cacheWrite": "Escritura de caché", + "contextSidebar.tokens.cacheHit": "Acierto de caché", "contextSidebar.actions.copyJson": "Copiar JSON", "contextSidebar.actions.copy": "Copiar", "contextSidebar.actions.copied": "Copiado", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 356bd414..c1530922 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1031,6 +1031,7 @@ export const dict = { 'contextSidebar.tokens.reasoning': 'Raisonnement', 'contextSidebar.tokens.cacheRead': 'Lecture du cache', 'contextSidebar.tokens.cacheWrite': 'Écriture du cache', + 'contextSidebar.tokens.cacheHit': 'Succès du cache', 'contextSidebar.actions.copyJson': 'Copier JSON', 'contextSidebar.actions.copy': 'Copie', 'contextSidebar.actions.copied': 'Copié', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index fc4b7300..0dac5ae8 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1161,6 +1161,7 @@ export const dict: Record = { 'contextSidebar.tokens.reasoning': '추론', 'contextSidebar.tokens.cacheRead': '캐시 읽기', 'contextSidebar.tokens.cacheWrite': '캐시 쓰기', + 'contextSidebar.tokens.cacheHit': '캐시 적중', 'contextSidebar.actions.copyJson': 'JSON 복사', 'contextSidebar.actions.copy': '복사', 'contextSidebar.actions.copied': '복사됨', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 1fb9f0aa..73e62e7c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1322,6 +1322,7 @@ export const dict: Record = { 'contextSidebar.stats.user': 'Użytkownik', 'contextSidebar.tokens.cacheRead': 'Odczyt cache', 'contextSidebar.tokens.cacheWrite': 'Zapis cache', + 'contextSidebar.tokens.cacheHit': 'Trafienie cache', 'contextSidebar.tokens.input': 'Wejście', 'contextSidebar.tokens.output': 'Wyjście', 'contextSidebar.tokens.reasoning': 'Rozumowanie', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index f653a34b..1e77dec9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1124,6 +1124,7 @@ export const dict: Record = { "contextSidebar.tokens.reasoning": "Raciocínio", "contextSidebar.tokens.cacheRead": "Leitura de cache", "contextSidebar.tokens.cacheWrite": "Escrita de cache", + "contextSidebar.tokens.cacheHit": "Acerto de cache", "contextSidebar.actions.copyJson": "Copiar JSON", "contextSidebar.actions.copy": "Copiar", "contextSidebar.actions.copied": "Copiado", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1a0cbe50..a47c2b23 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1124,6 +1124,7 @@ export const dict: Record = { "contextSidebar.tokens.reasoning": "Міркування", "contextSidebar.tokens.cacheRead": "Читання кешу", "contextSidebar.tokens.cacheWrite": "Запис кешу", + "contextSidebar.tokens.cacheHit": "Влучання в кеш", "contextSidebar.actions.copyJson": "Скопіювати JSON", "contextSidebar.actions.copy": "Копіювати", "contextSidebar.actions.copied": "Скопійовано", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index fb4633f8..34c45be2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1124,6 +1124,7 @@ export const dict: Record = { 'contextSidebar.tokens.reasoning': '推理', 'contextSidebar.tokens.cacheRead': '缓存读取', 'contextSidebar.tokens.cacheWrite': '缓存写入', + 'contextSidebar.tokens.cacheHit': '缓存命中', 'contextSidebar.actions.copyJson': '复制 JSON', 'contextSidebar.actions.copy': '复制', 'contextSidebar.actions.copied': '已复制', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index f954f99d..83a004d6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1134,6 +1134,7 @@ export const dict: Record = { 'contextSidebar.tokens.reasoning': '推理', 'contextSidebar.tokens.cacheRead': '快取讀取', 'contextSidebar.tokens.cacheWrite': '快取寫入', + 'contextSidebar.tokens.cacheHit': '快取命中', 'contextSidebar.actions.copyJson': '複製 JSON', 'contextSidebar.actions.copy': '複製', 'contextSidebar.actions.copied': '已複製', diff --git a/packages/ui/src/stores/utils/tokenUtils.test.ts b/packages/ui/src/stores/utils/tokenUtils.test.ts new file mode 100644 index 00000000..63d8cf6d --- /dev/null +++ b/packages/ui/src/stores/utils/tokenUtils.test.ts @@ -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) + }) +}) diff --git a/packages/ui/src/stores/utils/tokenUtils.ts b/packages/ui/src/stores/utils/tokenUtils.ts index 1562ffc2..4b944449 100644 --- a/packages/ui/src/stores/utils/tokenUtils.ts +++ b/packages/ui/src/stores/utils/tokenUtils.ts @@ -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 }; +};