fix(hub,web): correct cache-aware usage accounting

via [HAPI](https://hapi.run)\n\nCo-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-08-03 14:02:55 +08:00
parent b3c49e09f1
commit 2e795f4cd4
10 changed files with 248 additions and 50 deletions
+18 -1
View File
@@ -31,7 +31,7 @@ export { SessionStore } from './sessionStore'
export { UserStore } from './userStore'
export { UsageStore } from './usageStore'
const SCHEMA_VERSION: number = 17
const SCHEMA_VERSION: number = 18
const REQUIRED_TABLES = [
'sessions',
'machines',
@@ -179,6 +179,7 @@ export class Store {
14: () => this.migrateFromV14ToV15(),
15: () => this.migrateFromV15ToV16(),
16: () => this.migrateFromV16ToV17(),
17: () => this.migrateFromV17ToV18(),
})
if (currentVersion === 0) {
@@ -352,6 +353,10 @@ export class Store {
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
last_input_tokens INTEGER,
last_output_tokens INTEGER,
last_cache_read_tokens INTEGER,
last_cache_creation_tokens INTEGER,
PRIMARY KEY (session_id, source_key),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
@@ -655,6 +660,18 @@ export class Store {
`)
}
private migrateFromV17ToV18(): void {
// Usage events are a rebuildable index; v18 changes their source key
// and baseline semantics, so stale rows must not be mixed with new ones.
this.db.exec(`
ALTER TABLE usage_events ADD COLUMN last_input_tokens INTEGER;
ALTER TABLE usage_events ADD COLUMN last_output_tokens INTEGER;
ALTER TABLE usage_events ADD COLUMN last_cache_read_tokens INTEGER;
ALTER TABLE usage_events ADD COLUMN last_cache_creation_tokens INTEGER;
DELETE FROM usage_events;
`)
}
private getSessionColumnNames(): Set<string> {
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
+3 -3
View File
@@ -10,7 +10,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
const store = new Store(':memory:')
expect(tableExists(store, 'message_epochs')).toBe(true)
expect(tableExists(store, 'session_scratchlist')).toBe(true)
expect(getUserVersion(store)).toBe(17)
expect(getUserVersion(store)).toBe(18)
store.close()
})
@@ -34,7 +34,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
store = new Store(dbPath)
expect(tableExists(store, 'message_epochs')).toBe(true)
expect(tableExists(store, 'session_scratchlist')).toBe(true)
expect(getUserVersion(store)).toBe(17)
expect(getUserVersion(store)).toBe(18)
expect(store.messages.getMessageEpoch('session-1')).toBe(0)
expect(store.messages.getMessages('session-1')).toHaveLength(1)
} finally {
@@ -72,7 +72,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
store = new Store(dbPath)
expect(tableExists(store, 'message_epochs')).toBe(true)
expect(tableExists(store, 'session_scratchlist')).toBe(true)
expect(getUserVersion(store)).toBe(17)
expect(getUserVersion(store)).toBe(18)
expect(store.messages.getMessages('session-1')).toHaveLength(1)
} finally {
store?.close()
+5 -3
View File
@@ -17,7 +17,8 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
const store = new Store(':memory:')
const cols = getColumns(store, 'session_scratchlist')
expect(cols).toContain('attachments')
expect(getUserVersion(store)).toBe(17)
expect(getColumns(store, 'usage_events')).toContain('last_input_tokens')
expect(getUserVersion(store)).toBe(18)
store.close()
})
@@ -36,7 +37,8 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
store = new Store(dbPath)
const cols = getColumns(store, 'session_scratchlist')
expect(cols).toContain('attachments')
expect(getUserVersion(store)).toBe(17)
expect(getColumns(store, 'usage_events')).toContain('last_input_tokens')
expect(getUserVersion(store)).toBe(18)
} finally {
store?.close()
rmSync(dir, { recursive: true, force: true })
@@ -56,7 +58,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
store2 = new Store(dbPath)
const cols2 = getColumns(store2, 'session_scratchlist')
expect(cols2).toEqual(cols1)
expect(getUserVersion(store2)).toBe(17)
expect(getUserVersion(store2)).toBe(18)
} finally {
store2?.close()
store1?.close()
+38 -6
View File
@@ -14,6 +14,10 @@ export type UsageEvent = {
outputTokens: number
cacheReadTokens: number
cacheCreationTokens: number
lastInputTokens: number | null
lastOutputTokens: number | null
lastCacheReadTokens: number | null
lastCacheCreationTokens: number | null
}
type UsageEventRow = {
@@ -28,6 +32,10 @@ type UsageEventRow = {
output_tokens: number
cache_read_tokens: number
cache_creation_tokens: number
last_input_tokens: number | null
last_output_tokens: number | null
last_cache_read_tokens: number | null
last_cache_creation_tokens: number | null
}
function toUsageEvent(row: UsageEventRow): UsageEvent {
@@ -42,7 +50,11 @@ function toUsageEvent(row: UsageEventRow): UsageEvent {
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
cacheReadTokens: row.cache_read_tokens,
cacheCreationTokens: row.cache_creation_tokens
cacheCreationTokens: row.cache_creation_tokens,
lastInputTokens: row.last_input_tokens,
lastOutputTokens: row.last_output_tokens,
lastCacheReadTokens: row.last_cache_read_tokens,
lastCacheCreationTokens: row.last_cache_creation_tokens
}
}
@@ -62,7 +74,11 @@ export function upsertUsageEvents(db: Database, events: UsageEvent[]): void {
input_tokens,
output_tokens,
cache_read_tokens,
cache_creation_tokens
cache_creation_tokens,
last_input_tokens,
last_output_tokens,
last_cache_read_tokens,
last_cache_creation_tokens
) VALUES (
@session_id,
@source_key,
@@ -74,7 +90,11 @@ export function upsertUsageEvents(db: Database, events: UsageEvent[]): void {
@input_tokens,
@output_tokens,
@cache_read_tokens,
@cache_creation_tokens
@cache_creation_tokens,
@last_input_tokens,
@last_output_tokens,
@last_cache_read_tokens,
@last_cache_creation_tokens
)
ON CONFLICT(session_id, source_key)
DO UPDATE SET
@@ -86,7 +106,11 @@ export function upsertUsageEvents(db: Database, events: UsageEvent[]): void {
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_read_tokens = excluded.cache_read_tokens,
cache_creation_tokens = excluded.cache_creation_tokens
cache_creation_tokens = excluded.cache_creation_tokens,
last_input_tokens = excluded.last_input_tokens,
last_output_tokens = excluded.last_output_tokens,
last_cache_read_tokens = excluded.last_cache_read_tokens,
last_cache_creation_tokens = excluded.last_cache_creation_tokens
`)
for (const event of events) {
@@ -101,7 +125,11 @@ export function upsertUsageEvents(db: Database, events: UsageEvent[]): void {
input_tokens: event.inputTokens,
output_tokens: event.outputTokens,
cache_read_tokens: event.cacheReadTokens,
cache_creation_tokens: event.cacheCreationTokens
cache_creation_tokens: event.cacheCreationTokens,
last_input_tokens: event.lastInputTokens,
last_output_tokens: event.lastOutputTokens,
last_cache_read_tokens: event.lastCacheReadTokens,
last_cache_creation_tokens: event.lastCacheCreationTokens
})
}
})()
@@ -123,7 +151,11 @@ export function getUsageEvents(db: Database, sessionIds: string[]): UsageEvent[]
input_tokens,
output_tokens,
cache_read_tokens,
cache_creation_tokens
cache_creation_tokens,
last_input_tokens,
last_output_tokens,
last_cache_read_tokens,
last_cache_creation_tokens
FROM usage_events
WHERE session_id IN (${placeholders})
ORDER BY created_at ASC, source_seq ASC
+109 -14
View File
@@ -7,11 +7,11 @@ function addAgentMessage(store: Store, sessionId: string, content: unknown): voi
}
describe('usage service', () => {
it('deduplicates Claude stream fragments and diffs Codex cumulative snapshots', () => {
it('deduplicates Claude stream fragments and normalizes cached input', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession(
'usage-test',
{ path: '/tmp', host: 'test', flavor: 'codex' },
'claude-usage-test',
{ path: '/tmp', host: 'test', flavor: 'claude' },
null,
'default',
'test-model'
@@ -21,33 +21,128 @@ describe('usage service', () => {
type: 'output',
data: {
type: 'assistant',
message: { id: 'claude-message', model: 'claude-test', usage: { input_tokens: 10, output_tokens: 2 } }
message: {
id: 'claude-message',
model: 'claude-test',
usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 80 }
}
}
})
addAgentMessage(store, session.id, {
type: 'output',
data: {
type: 'assistant',
message: { id: 'claude-message', model: 'claude-test', usage: { input_tokens: 12, output_tokens: 3 } }
message: {
id: 'claude-message',
model: 'claude-test',
usage: { input_tokens: 12, output_tokens: 3, cache_read_input_tokens: 90 }
}
}
})
const result = getUsageSummary(store, 'default', 'all')
expect(result.totals.requests).toBe(1)
expect(result.totals.inputTokens).toBe(102)
expect(result.totals.outputTokens).toBe(3)
expect(result.totals.cacheReadTokens).toBe(90)
expect(result.totals.totalTokens).toBe(105)
expect(result.totals.uncachedTokens).toBe(15)
expect(result.byModel.find((row) => row.key === 'claude-test')?.totalTokens).toBe(105)
store.close()
})
it('uses the latest request as the baseline for a resumed Codex thread', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession(
'codex-usage-test',
{ path: '/tmp', host: 'test', flavor: 'codex' },
null,
'default',
'test-model'
)
addAgentMessage(store, session.id, {
type: 'codex',
data: {
type: 'token_count',
thread_id: 'thread-1',
turn_id: 'turn-1',
scope_role: 'parent',
info: {
total: { inputTokens: 1_000, outputTokens: 100, cachedInputTokens: 800 },
last: { inputTokens: 100, outputTokens: 10, cachedInputTokens: 80 }
}
}
})
addAgentMessage(store, session.id, {
type: 'codex',
data: { type: 'token_count', thread_id: 'thread-1', scope_role: 'parent', info: { total: { inputTokens: 100, outputTokens: 10, cachedInputTokens: 80 } } }
data: {
type: 'token_count',
thread_id: 'thread-2',
turn_id: 'turn-1',
scope_role: 'parent',
info: {
total: { inputTokens: 1_000, outputTokens: 100, cachedInputTokens: 800 },
last: { inputTokens: 100, outputTokens: 10, cachedInputTokens: 80 }
}
}
})
addAgentMessage(store, session.id, {
type: 'codex',
data: { type: 'token_count', thread_id: 'thread-1', scope_role: 'parent', info: { total: { inputTokens: 140, outputTokens: 15, cachedInputTokens: 100 } } }
data: {
type: 'token_count',
thread_id: 'thread-1',
turn_id: 'turn-2',
scope_role: 'parent',
info: {
total: { inputTokens: 1_140, outputTokens: 115, cachedInputTokens: 900 },
last: { inputTokens: 140, outputTokens: 15, cachedInputTokens: 100 }
}
}
})
const result = getUsageSummary(store, 'default', 'all')
expect(result.totals.requests).toBe(3)
expect(result.totals.inputTokens).toBe(152)
expect(result.totals.outputTokens).toBe(18)
expect(result.totals.cacheReadTokens).toBe(100)
expect(result.totals.totalTokens).toBe(170)
expect(result.byAgent.find((row) => row.key === 'claude')?.requests).toBe(1)
expect(result.byModel.find((row) => row.key === 'claude-test')?.totalTokens).toBe(15)
expect(result.totals.requests).toBe(2)
expect(result.totals.inputTokens).toBe(240)
expect(result.totals.outputTokens).toBe(25)
expect(result.totals.cacheReadTokens).toBe(180)
expect(result.totals.totalTokens).toBe(265)
expect(result.totals.uncachedTokens).toBe(85)
store.close()
})
it('treats ACP usage totals as per-request deltas', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession(
'kimi-usage-test',
{ path: '/tmp', host: 'test', flavor: 'kimi' },
null,
'default',
'kimi-model'
)
addAgentMessage(store, session.id, {
type: 'codex',
data: {
type: 'token_count',
info: { total: { inputTokens: 100, outputTokens: 10, cachedInputTokens: 80 } }
}
})
addAgentMessage(store, session.id, {
type: 'codex',
data: {
type: 'token_count',
info: { total: { inputTokens: 140, outputTokens: 15, cachedInputTokens: 100 } }
}
})
const result = getUsageSummary(store, 'default', 'all')
expect(result.totals.requests).toBe(2)
expect(result.totals.inputTokens).toBe(240)
expect(result.totals.outputTokens).toBe(25)
expect(result.totals.cacheReadTokens).toBe(180)
expect(result.totals.totalTokens).toBe(265)
expect(result.totals.uncachedTokens).toBe(85)
store.close()
})
})
+63 -16
View File
@@ -70,20 +70,28 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens
cacheCreationTokens,
lastInputTokens: null,
lastOutputTokens: null,
lastCacheReadTokens: null,
lastCacheCreationTokens: null
}
}
// Codex and ACP-compatible backends forward token_count snapshots. The
// `total` object is cumulative for a thread; aggregation below diffs it.
// Codex forwards cumulative thread totals plus the most recent request.
// ACP-compatible backends wrap per-request usage in `total`, so only Codex
// should be diffed as a cumulative stream.
if (data.type === 'token_count' || data.type === 'usage') {
const info = asRecord(data.info) ?? data
const total = asRecord(info.total) ?? info
const agent = sessionAgent(session)
const total = asRecord(info.total) ?? (agent === 'codex' ? null : info)
if (!total) return null
const inputTokens = firstCount(total, 'inputTokens', 'input_tokens')
const outputTokens = firstCount(total, 'outputTokens', 'output_tokens')
const cacheReadTokens = firstCount(total, 'cachedInputTokens', 'cached_input_tokens', 'cacheReadTokens', 'cache_read_input_tokens')
const cacheCreationTokens = firstCount(total, 'cacheWriteInputTokens', 'cache_write_input_tokens', 'cacheCreationTokens', 'cache_creation_input_tokens')
if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) return null
const last = asRecord(info.last)
const threadId = typeof data.threadId === 'string'
? data.threadId
: typeof data.thread_id === 'string'
@@ -94,19 +102,34 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE
: typeof data.scope_role === 'string'
? data.scope_role
: 'parent'
const agent = sessionAgent(session)
const isCumulative = agent === 'codex'
const turnId = typeof data.turnId === 'string'
? data.turnId
: typeof data.turn_id === 'string'
? data.turn_id
: ''
return {
sessionId: session.id,
sourceKey: `cumulative|${threadId}|${scope}|${message.id}`,
sourceKey: isCumulative
? `cumulative|${threadId}|${scope}|${turnId}|${message.id}`
: `delta|${message.id}`,
sourceSeq: message.seq,
createdAt: message.createdAt,
agent,
model: sessionModel(session),
kind: 'cumulative',
kind: isCumulative ? 'cumulative' : 'delta',
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens
cacheCreationTokens,
lastInputTokens: last ? firstCount(last, 'inputTokens', 'input_tokens') : null,
lastOutputTokens: last ? firstCount(last, 'outputTokens', 'output_tokens') : null,
lastCacheReadTokens: last
? firstCount(last, 'cachedInputTokens', 'cached_input_tokens', 'cacheReadTokens', 'cache_read_input_tokens')
: null,
lastCacheCreationTokens: last
? firstCount(last, 'cacheWriteInputTokens', 'cache_write_input_tokens', 'cacheCreationTokens', 'cache_creation_input_tokens')
: null
}
}
@@ -137,6 +160,7 @@ function emptyTotals(): Totals {
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalTokens: 0,
uncachedTokens: 0,
requests: 0
}
}
@@ -149,9 +173,15 @@ function addTotals(target: Totals, inputTokens: number, outputTokens: number, ca
// Codex/Kimi inputTokens already includes cached input. Claude's raw
// input_tokens excludes cache fields and is normalized before this call.
target.totalTokens += inputTokens + outputTokens
target.uncachedTokens += Math.max(0, inputTokens - cacheReadTokens) + outputTokens
target.requests += 1
}
function cumulativeDelta(current: number, previous: number | null, last: number | null): number {
if (previous === null) return last ?? current
return current >= previous ? current - previous : last ?? current
}
function toBucket(key: string, totals: Totals): UsageSummaryBucket {
return { key, ...totals }
}
@@ -180,24 +210,41 @@ export function getUsageSummary(store: Store, namespace: string, range: string |
const byModel = new Map<string, Totals>()
const sessionsWithUsage = new Set<string>()
const cumulativePrevious = new Map<string, [number, number, number, number]>()
const cumulativeFingerprints = new Set<string>()
for (const event of events) {
let inputTokens = event.inputTokens
let outputTokens = event.outputTokens
let cacheReadTokens = event.cacheReadTokens
let cacheCreationTokens = event.cacheCreationTokens
let duplicateCumulativeEvent = false
if (event.kind === 'cumulative') {
const streamKey = event.sourceKey.split('|').slice(0, 3).join('|')
const sourceParts = event.sourceKey.split('|')
const streamKey = sourceParts.slice(0, 3).join('|')
const previous = cumulativePrevious.get(streamKey)
if (previous) {
inputTokens = inputTokens >= previous[0] ? inputTokens - previous[0] : inputTokens
outputTokens = outputTokens >= previous[1] ? outputTokens - previous[1] : outputTokens
cacheReadTokens = cacheReadTokens >= previous[2] ? cacheReadTokens - previous[2] : cacheReadTokens
cacheCreationTokens = cacheCreationTokens >= previous[3] ? cacheCreationTokens - previous[3] : cacheCreationTokens
}
inputTokens = cumulativeDelta(inputTokens, previous?.[0] ?? null, event.lastInputTokens)
outputTokens = cumulativeDelta(outputTokens, previous?.[1] ?? null, event.lastOutputTokens)
cacheReadTokens = cumulativeDelta(cacheReadTokens, previous?.[2] ?? null, event.lastCacheReadTokens)
cacheCreationTokens = cumulativeDelta(cacheCreationTokens, previous?.[3] ?? null, event.lastCacheCreationTokens)
cumulativePrevious.set(streamKey, [event.inputTokens, event.outputTokens, event.cacheReadTokens, event.cacheCreationTokens])
const turnId = sourceParts[3]
if (turnId) {
const fingerprint = [
turnId,
event.inputTokens,
event.outputTokens,
event.cacheReadTokens,
event.cacheCreationTokens,
event.lastInputTokens,
event.lastOutputTokens,
event.lastCacheReadTokens,
event.lastCacheCreationTokens
].join('|')
duplicateCumulativeEvent = cumulativeFingerprints.has(fingerprint)
cumulativeFingerprints.add(fingerprint)
}
}
if (!isInRange(event) || inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) continue
if (duplicateCumulativeEvent || !isInRange(event) || inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) continue
const normalizedInputTokens = event.agent === 'claude'
? inputTokens + cacheReadTokens + cacheCreationTokens
: inputTokens
+2
View File
@@ -699,6 +699,7 @@ export type UsageSummaryBucket = {
cacheReadTokens: number
cacheCreationTokens: number
totalTokens: number
uncachedTokens: number
requests: number
}
@@ -713,6 +714,7 @@ export type UsageSummaryResponse = {
cacheReadTokens: number
cacheCreationTokens: number
totalTokens: number
uncachedTokens: number
requests: number
sessions: number
}
+4 -3
View File
@@ -643,7 +643,7 @@ export default {
'settings.storage.refreshing': 'Refreshing…',
'settings.usage.title': 'Token usage',
'settings.usage.summary': 'Token-only usage dashboard',
'settings.usage.description': 'Token consumption recorded by HAPI sessions. Costs are not included.',
'settings.usage.description': 'Tokens processed by HAPI sessions. Cache hits are counted once and shown separately; costs are not included.',
'settings.usage.range.label': 'Usage range',
'settings.usage.range.7d': '7 days',
'settings.usage.range.30d': '30 days',
@@ -651,10 +651,11 @@ export default {
'settings.usage.loading': 'Loading token usage…',
'settings.usage.error': 'Unable to load token usage',
'settings.usage.empty': 'No token usage recorded for this range.',
'settings.usage.total': 'Total tokens (input + output)',
'settings.usage.total': 'Processed tokens (cache included)',
'settings.usage.uncached': 'Non-cached tokens',
'settings.usage.input': 'Input tokens (includes cache)',
'settings.usage.output': 'Output tokens',
'settings.usage.cacheRead': 'Cache read (included)',
'settings.usage.cacheRead': 'Cache read (included in input)',
'settings.usage.cacheCreation': 'Cache creation (included)',
'settings.usage.requests': 'Requests',
'settings.usage.daily.title': 'Daily trend',
+4 -3
View File
@@ -647,7 +647,7 @@ export default {
'settings.storage.refreshing': '正在刷新…',
'settings.usage.title': 'Token 用量',
'settings.usage.summary': 'Token 用量看板',
'settings.usage.description': '统计 HAPI 会话记录的 Token 消耗,不包含费用估算。',
'settings.usage.description': '统计 HAPI 会话处理的 Token;缓存命中只计算一次并单独展示,不包含费用估算。',
'settings.usage.range.label': '用量范围',
'settings.usage.range.7d': '近 7 天',
'settings.usage.range.30d': '近 30 天',
@@ -655,10 +655,11 @@ export default {
'settings.usage.loading': '正在加载 Token 用量…',
'settings.usage.error': '无法加载 Token 用量',
'settings.usage.empty': '此范围内没有记录到 Token 用量。',
'settings.usage.total': ' Token输入 + 输出',
'settings.usage.total': '处理 Token含缓存',
'settings.usage.uncached': '非缓存 Token',
'settings.usage.input': '输入 Token(含缓存)',
'settings.usage.output': '输出 Token',
'settings.usage.cacheRead': '缓存命中(已计入)',
'settings.usage.cacheRead': '缓存命中(已计入输入',
'settings.usage.cacheCreation': '缓存创建(已计入)',
'settings.usage.requests': '请求数',
'settings.usage.daily.title': '每日趋势',
+2 -1
View File
@@ -76,9 +76,10 @@ export default function SettingsUsagePage() {
{query.error ? <SettingsSection><SettingsRow label={t('settings.usage.error')} description={query.error instanceof Error ? query.error.message : undefined} /></SettingsSection> : null}
{query.data ? (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['settings.usage.total', query.data.totals.totalTokens],
['settings.usage.uncached', query.data.totals.uncachedTokens],
['settings.usage.input', query.data.totals.inputTokens],
['settings.usage.output', query.data.totals.outputTokens],
['settings.usage.cacheRead', query.data.totals.cacheReadTokens],