fix(client): count only the sessions each KPI figure covers

The cost cell's sub-line now counts the sessions whose spend the price
book could estimate (costSessions), and the cache-hit cell's the
sessions whose usage feeds the rate (usageSessions) — a session with
usage but no book rates feeds the rate while pricing to nothing, and a
session without usage counts toward neither figure.
This commit is contained in:
Bowen Liang committed 2026-09-17 00:53:49 +08:00
1 parent 0d34b468fb
commit 0003ceabaa
4 files changed
+40 -4

No files matched your search

+2 -2
View File
@@ -150,12 +150,12 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cost')}</span>
<span className="lc-stat-value">{kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.sessions })}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.costSessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cacheHit')}</span>
<span className="lc-stat-value">{kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.sessions })}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.toolCalls')}</span>
+14
View File
@@ -392,8 +392,12 @@ export interface OverviewKpis {
turns: number
/** Estimated spend in the display currency (null: nothing priced). */
cost: number | null
/** Sessions whose spend the book could price (the cost cell's sub-line). */
costSessions: number
/** Cache-hit share of billed input, truncated (null: nothing billed). */
cacheHit: string | null
/** Sessions whose usage feeds the cache-hit rate (the cache-hit cell's sub-line). */
usageSessions: number
/** Their completed tool calls. */
toolCalls: number
/** Their summed tool-run time (the tool-calls cell's sub-line). */
@@ -417,7 +421,15 @@ export function kpisOf(
let toolsMs = 0
let calls = 0
let wallMs = 0
let costSessions = 0
let usageSessions = 0
for (const row of rows) {
const cost = row.timeline?.cost
// Each qualifying sub-line counts the sessions its own figure covers: a
// session with usage but no book rates feeds the cache-hit rate while
// pricing to nothing.
if (estimateSessionCost(cost, prices, currency) !== null) costSessions++
if (usageTotalsOf(cost) !== null) usageSessions++
turns += turnsOf(row.timeline)
const timing = row.timeline?.timing
toolCalls += timing?.toolCalls ?? 0
@@ -431,7 +443,9 @@ export function kpisOf(
tokens: totals?.total ?? 0,
turns,
cost: estimateSessionCost(usage, prices, currency),
costSessions,
cacheHit: totals === null ? null : cacheHitPercent(totals.cacheRead, totals.input + totals.cacheRead + totals.cacheWrite),
usageSessions,
toolCalls,
toolsMs,
calls,
@@ -139,9 +139,10 @@ describe('OverviewPanel', () => {
assert.equal(values[5], '3m0s')
assert.ok(text(m.container).includes('tool runs 40.0s'), 'the tool calls cell qualifies with the summed run time')
assert.ok(text(m.container).includes('8 model calls'), 'the active-time cell qualifies with the model-call count')
// Cost and cache hit qualify with the session count they aggregate over.
// Cost and cache hit qualify with the session count each figure covers
// (only session a carries usage; b folds no cost at all).
const subs = queryAll(m.container, '.lc-stat-sub').map(el => el.textContent)
assert.deepEqual(subs.slice(2, 4), ['across 2 sessions', 'across 2 sessions'])
assert.deepEqual(subs.slice(2, 4), ['across 1 sessions', 'across 1 sessions'])
// Heatmap drew cells for the two ledger days.
assert.ok(queryAll(m.container, 'button.lc-heat-cell').length >= 2)
// Cards: a (current, running, grouped), b, and c is outside the 30d range.
+21
View File
@@ -348,18 +348,39 @@ describe('kpisOf', () => {
assert.equal(kpi.turns, 5)
assert.ok(kpi.cost !== null && Math.abs(kpi.cost - 195e-6) < 1e-12, '50×0.1 + 100×1 + 10×1 + 40×2 per 1M')
assert.equal(kpi.cacheHit, '31.25', '50 reads of 160 billed input, truncated')
assert.equal(kpi.costSessions, 1, 'only the priced session counts toward the cost cell')
assert.equal(kpi.usageSessions, 1, 'only the billed session feeds the cache-hit rate')
assert.equal(kpi.toolCalls, 7, 'tool calls sum across rows')
assert.equal(kpi.toolsMs, 20_000)
assert.equal(kpi.calls, 6)
assert.equal(kpi.wallMs, 120_000)
})
test('a session with usage the book cannot price feeds the cache-hit rate but prices to nothing', () => {
const rows = [
rowOf({ timeline: { cost: COST, requests: [] } as unknown as ContextTimeline }),
rowOf({
timeline: {
cost: { openai: { 'gpt-5': { peak: { uncached: 10, cacheRead: 5, cacheWrite: 1, output: 2 } } } },
requests: [],
} as unknown as ContextTimeline,
}),
rowOf(),
]
const kpi = kpisOf(rows, 3, prices, 'usd')
assert.equal(kpi.costSessions, 1, 'only the priced session counts toward the cost cell')
assert.equal(kpi.usageSessions, 2, 'both billed sessions feed the cache-hit rate')
assert.ok(kpi.cost !== null && Math.abs(kpi.cost - 195e-6) < 1e-12, 'the unpriced session adds nothing to the estimate')
})
test('an unbilled set zeroes and dashes', () => {
const kpi = kpisOf([rowOf()], 1, null, 'cny')
assert.equal(kpi.tokens, 0)
assert.equal(kpi.turns, 0)
assert.equal(kpi.cost, null)
assert.equal(kpi.cacheHit, null)
assert.equal(kpi.costSessions, 0)
assert.equal(kpi.usageSessions, 0)
assert.equal(kpi.toolCalls, 0, 'no timing folds to zeroed tools and time')
assert.equal(kpi.toolsMs, 0)
assert.equal(kpi.calls, 0)