From c3c6015a95ec85d33b26015747f2f082b2a4e34c Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Mon, 24 Aug 2026 17:57:50 +0800 Subject: [PATCH] feat: step-level cache hit rate on the step detail line Drop the heuristic estimate from the step header; show the provider-reported cache-hit share of the billed prompt (cacheRead / prompt, truncated to two decimals) instead. The host folds each request's cacheReadTokens into its record (stateVersion 9); the stats board reuses the same formula via a shared format helper. --- src/client/components/requestDetail.tsx | 7 ++++++- src/client/components/statsBoard.tsx | 13 +++++-------- src/client/format.ts | 13 +++++++++++++ src/client/i18n.ts | 2 ++ src/client/styles.css | 1 + src/host/fold.ts | 3 +++ src/host/timeline.ts | 4 +++- src/shared/types.ts | 6 ++++++ tests/chart.spec.ts | 4 ++++ tests/helpers/viewBed.ts | 4 ++-- tests/host.spec.ts | 18 ++++++++++++++++++ 11 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/client/components/requestDetail.tsx b/src/client/components/requestDetail.tsx index fb991dd..1b70503 100644 --- a/src/client/components/requestDetail.tsx +++ b/src/client/components/requestDetail.tsx @@ -1,6 +1,7 @@ import type * as ReactNS from 'react' import type { ContextEventRecord, RequestRecord } from '../../shared/types' import { partsOf, CATS } from '../categories' +import { cacheHitPercent } from '../format' import type { StackedBarProps } from './stackedBar' import type { ViewKit } from '../viewkit' @@ -37,13 +38,17 @@ export function makeRequestDetail( : null} {isTurn ? {t('detail.lastStep')} : null} {fmtTime(req.time)} - {t('detail.estTotal', { n: fmt(req.total) })} {req.prompt !== undefined ? {t('detail.actual', { n: fmt(req.prompt) })} : null} {req.output !== undefined ? {t('detail.output', { n: fmt(req.output) })} : null} + {/* Step-level cache hit: the cache-served half of the billed prompt; absent on hosts + that do not fold `cacheRead` (and on usage-less requests) — the figure drops out. */} + {req.prompt !== undefined && req.cacheRead !== undefined + ? {t('detail.cache', { n: cacheHitPercent(req.cacheRead, req.prompt) ?? '—' })} + : null}
diff --git a/src/client/components/statsBoard.tsx b/src/client/components/statsBoard.tsx index f87b56d..29bd336 100644 --- a/src/client/components/statsBoard.tsx +++ b/src/client/components/statsBoard.tsx @@ -8,25 +8,22 @@ import type * as ReactNS from 'react' import type { ContextEventRecord, RequestRecord, SessionCostUsage, TokenUsage } from '../../shared/types' import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../cost' import type { CostCurrency } from '../cost' +import { cacheHitPercent as cacheHitPercentOf } from '../format' import { numOf } from '../services' import type { ViewKit } from '../viewkit' import { React } from '../react' /** - * Cache-hit share of billed prompt-side input — same three disjoint buckets as the harness chat line (`cacheReadTokens` over uncached + - * reads + writes), but deliberately TRUNCATES to two decimals (cut, not round), unlike that line's integer rounding; null when nothing was - * billed. The 1e-9 epsilon absorbs only float noise (integer token counts never sit that close to a boundary); `numOf` keeps malformed - * payloads at a dash. + * Cache-hit share of billed prompt-side input — same three disjoint buckets + * as the harness chat line (`cacheReadTokens` over uncached + reads + writes) + * and the step line's 缓存 figure; null when nothing was billed. */ function cacheHitPercent(usage: TokenUsage): string | null { const uncached = numOf(usage.uncachedInputTokens) const reads = numOf(usage.cacheReadTokens) const writes = numOf(usage.cacheWriteTokens) - const billed = uncached + reads + writes - if (billed === 0) return null - const hundredths = Math.trunc((reads / billed) * 10000 + 1e-9) - return `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}` + return cacheHitPercentOf(reads, uncached + reads + writes) } export function makeStatsBoard(kit: ViewKit): (props: { diff --git a/src/client/format.ts b/src/client/format.ts index 17ce0e5..5baef43 100644 --- a/src/client/format.ts +++ b/src/client/format.ts @@ -17,6 +17,19 @@ export function fmtBytes(n: number | null | undefined): string { return String(Math.round(n)) + ' B' } +/** + * Cache-hit share of billed prompt-side input (`reads` over `billed`), + * TRUNCATED to two decimals (cut, not round) — same formula as the harness + * chat stats line's '缓存命中' figure and the stats board's cell. Null when + * nothing was billed. The 1e-9 epsilon absorbs only float noise (integer + * token counts never sit that close to a boundary). + */ +export function cacheHitPercent(reads: number, billed: number): string | null { + if (!(billed > 0)) return null + const hundredths = Math.trunc((reads / billed) * 10000 + 1e-9) + return `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}` +} + export function fmtTime(t: number): string { // en-GB 24-hour clock zero-pads HH:MM:SS without a helper; invalid dates must show '—' (toLocaleTimeString throws RangeError). const d = new Date(t) diff --git a/src/client/i18n.ts b/src/client/i18n.ts index e68979b..5508d89 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -51,6 +51,7 @@ export const DICT_ZH: Record = { 'detail.estTotal': '估算合计 ≈ {n}', 'detail.actual': '实际 prompt {n}', 'detail.output': '输出 {n}', + 'detail.cache': '缓存 {n}%', 'events.title': '上下文事件', 'events.empty': '暂无上下文事件(压缩、注入、模型切换会出现在这里)', 'events.at': '第 {t} 轮 · 第 {s} 步', @@ -174,6 +175,7 @@ export const DICT_EN: Record = { 'detail.estTotal': 'Estimated ≈ {n}', 'detail.actual': 'Actual Prompt {n}', 'detail.output': 'Output {n}', + 'detail.cache': 'Cache {n}%', 'events.title': 'Context Events', 'events.empty': 'No context events yet (compaction, injections, model switches appear here)', 'events.at': 'Turn {t} · Step {s}', diff --git a/src/client/styles.css b/src/client/styles.css index c7cc1ae..839882c 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -132,6 +132,7 @@ .lc-detail-head b { color: var(--dsw-alias-label-primary); } .lc-detail-marker { color: var(--dsw-alias-state-warn-primary); font-size: 11px; background: var(--dsw-alias-bg-layer-2); border-radius: 6px; padding: 1px 7px; } .lc-detail-head .lc-actual { color: var(--dsw-alias-state-success-primary); } +.lc-detail-head .lc-cache { color: var(--dsw-alias-state-success-primary); } .lc-detail-tag { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); } .lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; } .lc-detail-row { display: flex; align-items: center; gap: 8px; } diff --git a/src/host/fold.ts b/src/host/fold.ts index 39aa282..f7661da 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -603,6 +603,9 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // already includes reasoningTokens. No separate prompt/output field // exists in the durable vocabulary. record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0) + // Cache-hit share of the billed prompt (the step line's 缓存 figure): + // keep the cache-served half of `prompt`; absent = no cache buckets. + if (typeof usage.cacheReadTokens === 'number') record.cacheRead = usage.cacheReadTokens if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens accumulateCost(s, event.time, usage) } diff --git a/src/host/timeline.ts b/src/host/timeline.ts index fe98351..186a470 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -56,6 +56,7 @@ const requestRecordSchema = z.object({ tool: z.number().int().nonnegative(), total: z.number().int().nonnegative(), prompt: z.number().int().nonnegative().optional(), + cacheRead: z.number().int().nonnegative().optional(), output: z.number().int().nonnegative().optional(), stepCount: z.number().int().positive().optional(), }).strict() @@ -192,7 +193,8 @@ export function createContextTimelineDefinition(config: Config): ProjectionDefin // 6: image blocks reprice via the official vision calculator instead of the meter's generic JSON branch; cached rows refolded. // 7: the whole-session image count (`images`) joined the state; cached rows refolded. // 8: the image count moved to per-node `imgs` (live-surface cell); cached rows refolded. - stateVersion: 8, + // 9: per-request billed cache-read tokens (`cacheRead`) joined request records; cached rows refolded. + stateVersion: 9, } return definition } diff --git a/src/shared/types.ts b/src/shared/types.ts index c330791..954879b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -237,6 +237,12 @@ export interface RequestRecord { tool: number total: number prompt?: number + /** + * Billed cache-read (served) prompt tokens of this request — the + * hit-rate numerator against `prompt` (input + cacheRead + cacheWrite). + * Absent on older hosts / usage-less requests; zero is a real value. + */ + cacheRead?: number output?: number /** * Turn-mode aggregate marker, set by the Client's aggregateByTurn (one bar diff --git a/tests/chart.spec.ts b/tests/chart.spec.ts index 22c7faf..29b6ee7 100644 --- a/tests/chart.spec.ts +++ b/tests/chart.spec.ts @@ -60,11 +60,15 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul let tr = renderView() assert.match(detailStep(tr), /Turn 3/, 'detail defaults to the newest request (Turn 3)') + assert.match(detailStep(tr), /Actual Prompt 83\.0k/, 'detail shows the billed prompt of the newest request') + assert.match(detailStep(tr), /Cache 99\.39%/, 'step-level cache hit = cacheRead / billed prompt, two decimals (truncated)') + assert.ok(!detailStep(tr).includes('Estimated') && !detailStep(tr).includes('estTotal'), 'the heuristic estimate line is gone from the step detail') assert.equal(byClass(tr, 'lc-bar-hovered').length, 0, 'no hovered bar initially') ctxSlots[1][1](3) tr = renderView() assert.match(detailStep(tr), /Turn 2/, 'hovering a bar links the detail below to it') + assert.ok(!detailStep(tr).includes('Cache'), 'a usage-less request shows no cache figure') const hovered = byClass(tr, 'lc-bar-hovered') assert.equal(hovered.length, 1, 'exactly one hovered bar') assert.equal(hovered[0].args[1]['data-seq'], 3, 'hovered bar is seq 3') diff --git a/tests/helpers/viewBed.ts b/tests/helpers/viewBed.ts index 65e422b..ffd4ca2 100644 --- a/tests/helpers/viewBed.ts +++ b/tests/helpers/viewBed.ts @@ -57,7 +57,7 @@ export const FAKE_PRIMITIVES = { MarkdownText: (p) => ({ kind: 'element', args: ['div', { className: 'lc-md-stub' }, p.text] }), } -export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'form.notice': 'Notice', 'ev.mode.plan.on': 'Plan mode on', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build', +export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'detail.actual': 'Actual Prompt {n}', 'detail.output': 'Output {n}', 'detail.cache': 'Cache {n}%', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'form.notice': 'Notice', 'ev.mode.plan.on': 'Plan mode on', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build', 'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'block.line': '1 line', 'block.lines': '{n} lines', 'call.ok': 'OK', 'call.fail': 'Failed', 'call.exit': 'exit {n}', 'node.failed': 'Tool execution failed', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens', 'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel', 'settings.gran': 'Default trend granularity', 'settings.mode': 'Default trend display', 'settings.expand': 'Expand', 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.diff': 'Diff' } /** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */ @@ -68,7 +68,7 @@ export const snapshot = { { seq: 1, turn: 1, step: 0, time: 1000, system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100, prompt: 95 }, { seq: 2, turn: 1, step: 1, time: 2000, system: 10, tools: 20, user: 25, inject: 5, assistant: 10, tool: 20, total: 90 }, { seq: 3, turn: 2, step: 0, time: 3000, system: 10, tools: 20, user: 40, inject: 5, assistant: 12, tool: 20, total: 107 }, - { seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000 }, + { seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000, cacheRead: 82499 }, ], events: [], nodes: [ { seq: 1, cat: 'user', tokens: 10, text: 'first message', time: 1000 }, diff --git a/tests/host.spec.ts b/tests/host.spec.ts index e3bbe60..c1dd723 100644 --- a/tests/host.spec.ts +++ b/tests/host.spec.ts @@ -401,6 +401,7 @@ test('host half: projection unit, fold semantics, attribution, retention, determ { seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30, reasoningTokens: 10 }, message: { content: [] } } }, ]) assert.equal(usageLog.requests[0].prompt, 1000, 'prompt side sums the disjoint input buckets') + assert.equal(usageLog.requests[0].cacheRead, 150, 'cache-served prompt tokens ride the request record (hit-rate numerator)') assert.equal(usageLog.requests[0].output, 30, 'output is outputTokens (reasoning already inside)') assert.equal(usageLog.cost, undefined, 'no model name -> nothing priced into the cost totals') assert.equal(v.cost, undefined, 'a non-flash/pro model (deepseek-v4) is not priced') @@ -479,6 +480,23 @@ test('host half: projection unit, fold semantics, attribution, retention, determ // stay serializable. const armed = def.apply(def.init(), { seq: 1, type: 'compaction/summary', time: 1000, data: { shadowedSeqs: [2], shadowedTokenCount: 10 } }) assert.ok(snapshotJsonValue(armed) !== undefined, 'armed pendingShadowedSeqs state is still plain JSON') + // `cacheRead` follows the same absence rule: present only when the provider + // reported cache-served tokens (a real 0 included); usage-less requests + // never materialize an `undefined`-valued property. + { + let st = def.init() + for (const ev of [ + { seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 100, cacheReadTokens: 900 }, message: { content: [] } } }, + { seq: 2, type: 'assistant/message', time: 2000, data: { turn: 1, step: 2, message: { content: [] } } }, + ]) st = def.apply(st, ev) + assert.equal(st.requests[0].cacheRead, 900, 'cacheRead folds from cacheReadTokens') + assert.ok(!('cacheRead' in st.requests[1]), 'usage-less requests keep cacheRead absent (plain-JSON precondition)') + assert.ok(snapshotJsonValue(st) !== undefined, 'cacheRead-carrying state stays losslessly serializable') + assert.equal(def.stateSchema.parse(st).requests[0].cacheRead, 900, 'stateSchema round-trips cacheRead') + const view = def.view(st) + assert.equal(view.requests[0].cacheRead, 900, 'cacheRead rides the wire view') + assert.ok(!('cacheRead' in view.requests[1]), 'wire view keeps cacheRead absent') + } // -- dual-contract compatibility (dsh 0.1.1-rc.1+): each unit carries the // NEW contract fields alongside the old ones. `stateSchema` validates the