mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+18
-1
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '每日趋势',
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user