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.
This commit is contained in:
bowenliang123 committed 2026-08-24 17:57:50 +08:00
1 parent 1e1dc14cd1
commit c3c6015a95
11 files changed
+63 -12

No files matched your search

+6 -1
View File
@@ -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 ? <span className="lc-detail-tag">{t('detail.lastStep')}</span> : null}
<span>{fmtTime(req.time)}</span>
<span>{t('detail.estTotal', { n: fmt(req.total) })}</span>
{req.prompt !== undefined
? <span className="lc-actual">{t('detail.actual', { n: fmt(req.prompt) })}</span>
: null}
{req.output !== undefined
? <span>{t('detail.output', { n: fmt(req.output) })}</span>
: 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
? <span className="lc-cache">{t('detail.cache', { n: cacheHitPercent(req.cacheRead, req.prompt) ?? '—' })}</span>
: null}
</div>
<StackedBar parts={partsOf(req)} height={10} />
<div className="lc-detail-rows">
+5 -8
View File
@@ -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: {
+13
View File
@@ -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)
+2
View File
@@ -51,6 +51,7 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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}',
+1
View File
@@ -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; }
+3
View File
@@ -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)
}
+3 -1
View File
@@ -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
}
+6
View File
@@ -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
+4
View File
@@ -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')
+2 -2
View File
@@ -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 },
+18
View File
@@ -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