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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
284d72bef2
commit
e20cfa2dbc
@@ -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 ── */}
|
||||
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
|
||||
<div className="typography-micro text-muted-foreground">{t('contextSidebar.section.lastAssistantMessage')}</div>
|
||||
<div className="mt-2.5 grid grid-cols-3 gap-x-4 gap-y-2.5">
|
||||
<div className="typography-micro text-muted-foreground mb-2.5">{t('contextSidebar.section.lastAssistantMessage')}</div>
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2.5">
|
||||
{([
|
||||
{ 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) => (
|
||||
<div key={item.label}>
|
||||
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
|
||||
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{formatNumber(item.value)}</div>
|
||||
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">
|
||||
{item.value !== null && item.value !== undefined
|
||||
? item.format === 'percent'
|
||||
? `${item.value.toFixed(1)}%`
|
||||
: formatNumber(item.value)
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextSidebar.tokens.reasoning': '추론',
|
||||
'contextSidebar.tokens.cacheRead': '캐시 읽기',
|
||||
'contextSidebar.tokens.cacheWrite': '캐시 쓰기',
|
||||
'contextSidebar.tokens.cacheHit': '캐시 적중',
|
||||
'contextSidebar.actions.copyJson': 'JSON 복사',
|
||||
'contextSidebar.actions.copy': '복사',
|
||||
'contextSidebar.actions.copied': '복사됨',
|
||||
|
||||
@@ -1322,6 +1322,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextSidebar.tokens.reasoning": "Міркування",
|
||||
"contextSidebar.tokens.cacheRead": "Читання кешу",
|
||||
"contextSidebar.tokens.cacheWrite": "Запис кешу",
|
||||
"contextSidebar.tokens.cacheHit": "Влучання в кеш",
|
||||
"contextSidebar.actions.copyJson": "Скопіювати JSON",
|
||||
"contextSidebar.actions.copy": "Копіювати",
|
||||
"contextSidebar.actions.copied": "Скопійовано",
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextSidebar.tokens.reasoning': '推理',
|
||||
'contextSidebar.tokens.cacheRead': '缓存读取',
|
||||
'contextSidebar.tokens.cacheWrite': '缓存写入',
|
||||
'contextSidebar.tokens.cacheHit': '缓存命中',
|
||||
'contextSidebar.actions.copyJson': '复制 JSON',
|
||||
'contextSidebar.actions.copy': '复制',
|
||||
'contextSidebar.actions.copied': '已复制',
|
||||
|
||||
@@ -1134,6 +1134,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextSidebar.tokens.reasoning': '推理',
|
||||
'contextSidebar.tokens.cacheRead': '快取讀取',
|
||||
'contextSidebar.tokens.cacheWrite': '快取寫入',
|
||||
'contextSidebar.tokens.cacheHit': '快取命中',
|
||||
'contextSidebar.actions.copyJson': '複製 JSON',
|
||||
'contextSidebar.actions.copy': '複製',
|
||||
'contextSidebar.actions.copied': '已複製',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user