diff --git a/src/client/components/overviewPanel.tsx b/src/client/components/overviewPanel.tsx index fd4149e..8c883d7 100644 --- a/src/client/components/overviewPanel.tsx +++ b/src/client/components/overviewPanel.tsx @@ -150,12 +150,12 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
{t('stats.cost')} {kpi.cost === null ? '—' : formatCost(kpi.cost, currency)} - {t('ov.kpi.sessionsSub', { n: kpi.sessions })} + {t('ov.kpi.sessionsSub', { n: kpi.costSessions })}
{t('stats.cacheHit')} {kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'} - {t('ov.kpi.sessionsSub', { n: kpi.sessions })} + {t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}
{t('stats.toolCalls')} diff --git a/src/client/overview.ts b/src/client/overview.ts index 0a6288a..c588214 100644 --- a/src/client/overview.ts +++ b/src/client/overview.ts @@ -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, diff --git a/tests/client/components/overviewPanel.spec.ts b/tests/client/components/overviewPanel.spec.ts index 83a6aa4..678c7b6 100644 --- a/tests/client/components/overviewPanel.spec.ts +++ b/tests/client/components/overviewPanel.spec.ts @@ -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. diff --git a/tests/client/overview.spec.ts b/tests/client/overview.spec.ts index fe969af..743a91d 100644 --- a/tests/client/overview.spec.ts +++ b/tests/client/overview.spec.ts @@ -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)