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:
1 parent
1e1dc14cd1
commit
c3c6015a95
11 files changed
+63
-12
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user