From 2e795f4cd463204050de37deb8d1713707630b39 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Mon, 3 Aug 2026 14:02:55 +0800 Subject: [PATCH] fix(hub,web): correct cache-aware usage accounting via [HAPI](https://hapi.run)\n\nCo-Authored-By: HAPI --- hub/src/store/index.ts | 19 ++++- hub/src/store/migration-v13.test.ts | 6 +- hub/src/store/migration-v15.test.ts | 8 +- hub/src/store/usage.ts | 44 ++++++++-- hub/src/sync/usageService.test.ts | 123 ++++++++++++++++++++++++---- hub/src/sync/usageService.ts | 79 ++++++++++++++---- shared/src/apiTypes.ts | 2 + web/src/lib/locales/en.ts | 7 +- web/src/lib/locales/zh-CN.ts | 7 +- web/src/routes/settings/usage.tsx | 3 +- 10 files changed, 248 insertions(+), 50 deletions(-) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 33319abf..7eea0d6b 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -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 { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v13.test.ts b/hub/src/store/migration-v13.test.ts index 892a2ee0..fb6e4259 100644 --- a/hub/src/store/migration-v13.test.ts +++ b/hub/src/store/migration-v13.test.ts @@ -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() diff --git a/hub/src/store/migration-v15.test.ts b/hub/src/store/migration-v15.test.ts index 3579903a..899c5c44 100644 --- a/hub/src/store/migration-v15.test.ts +++ b/hub/src/store/migration-v15.test.ts @@ -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() diff --git a/hub/src/store/usage.ts b/hub/src/store/usage.ts index 3befd256..50c2d04a 100644 --- a/hub/src/store/usage.ts +++ b/hub/src/store/usage.ts @@ -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 diff --git a/hub/src/sync/usageService.test.ts b/hub/src/sync/usageService.test.ts index f7627376..5e6c5e74 100644 --- a/hub/src/sync/usageService.test.ts +++ b/hub/src/sync/usageService.test.ts @@ -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() }) }) diff --git a/hub/src/sync/usageService.ts b/hub/src/sync/usageService.ts index fa92f39b..ec00d338 100644 --- a/hub/src/sync/usageService.ts +++ b/hub/src/sync/usageService.ts @@ -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() const sessionsWithUsage = new Set() const cumulativePrevious = new Map() + const cumulativeFingerprints = new Set() 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 diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 425d1543..142fc314 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -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 } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 6a731ab4..8ec1acf3 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2640329c..635a8145 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '每日趋势', diff --git a/web/src/routes/settings/usage.tsx b/web/src/routes/settings/usage.tsx index 74b0b984..2ff24f46 100644 --- a/web/src/routes/settings/usage.tsx +++ b/web/src/routes/settings/usage.tsx @@ -76,9 +76,10 @@ export default function SettingsUsagePage() { {query.error ? : null} {query.data ? ( <> -
+
{[ ['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],