diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 8512ec6e..33319abf 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -9,6 +9,7 @@ import { FcmStore } from './fcmStore' import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' +import { UsageStore } from './usageStore' export type { StoredMachine, @@ -28,8 +29,9 @@ export { FcmStore } from './fcmStore' export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' +export { UsageStore } from './usageStore' -const SCHEMA_VERSION: number = 16 +const SCHEMA_VERSION: number = 17 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -38,7 +40,8 @@ const REQUIRED_TABLES = [ 'users', 'push_subscriptions', 'fcm_devices', - 'session_scratchlist' + 'session_scratchlist', + 'usage_events' ] as const export class Store { @@ -53,6 +56,7 @@ export class Store { readonly push: PushStore readonly fcm: FcmStore readonly scratchlist: ScratchlistStore + readonly usage: UsageStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -105,6 +109,7 @@ export class Store { this.push = new PushStore(this.db) this.fcm = new FcmStore(this.db) this.scratchlist = new ScratchlistStore(this.db) + this.usage = new UsageStore(this.db) } /** @@ -173,6 +178,7 @@ export class Store { 13: () => this.migrateFromV13ToV14(), 14: () => this.migrateFromV14ToV15(), 15: () => this.migrateFromV15ToV16(), + 16: () => this.migrateFromV16ToV17(), }) if (currentVersion === 0) { @@ -333,6 +339,26 @@ export class Store { ); CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS usage_events ( + session_id TEXT NOT NULL, + source_key TEXT NOT NULL, + source_seq INTEGER NOT NULL, + created_at INTEGER NOT NULL, + agent TEXT NOT NULL, + model TEXT, + kind TEXT NOT NULL CHECK (kind IN ('delta', 'cumulative')), + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (session_id, source_key), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_usage_events_session_created + ON usage_events(session_id, created_at, source_seq); + CREATE INDEX IF NOT EXISTS idx_usage_events_created + ON usage_events(created_at); `) } @@ -605,6 +631,30 @@ export class Store { */ private migrateFromV15ToV16(): void {} + private migrateFromV16ToV17(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + session_id TEXT NOT NULL, + source_key TEXT NOT NULL, + source_seq INTEGER NOT NULL, + created_at INTEGER NOT NULL, + agent TEXT NOT NULL, + model TEXT, + kind TEXT NOT NULL CHECK (kind IN ('delta', 'cumulative')), + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (session_id, source_key), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_usage_events_session_created + ON usage_events(session_id, created_at, source_seq); + CREATE INDEX IF NOT EXISTS idx_usage_events_created + ON usage_events(created_at); + `) + } + 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 cc0d6e79..892a2ee0 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(16) + expect(getUserVersion(store)).toBe(17) 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(16) + expect(getUserVersion(store)).toBe(17) 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(16) + expect(getUserVersion(store)).toBe(17) 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 1231d1cf..3579903a 100644 --- a/hub/src/store/migration-v15.test.ts +++ b/hub/src/store/migration-v15.test.ts @@ -17,7 +17,7 @@ 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(16) + expect(getUserVersion(store)).toBe(17) store.close() }) @@ -36,7 +36,7 @@ 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(16) + expect(getUserVersion(store)).toBe(17) } finally { store?.close() rmSync(dir, { recursive: true, force: true }) @@ -56,7 +56,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(16) + expect(getUserVersion(store2)).toBe(17) } finally { store2?.close() store1?.close() diff --git a/hub/src/store/usage.ts b/hub/src/store/usage.ts new file mode 100644 index 00000000..3befd256 --- /dev/null +++ b/hub/src/store/usage.ts @@ -0,0 +1,147 @@ +import type { Database } from 'bun:sqlite' + +export type UsageEventKind = 'delta' | 'cumulative' + +export type UsageEvent = { + sessionId: string + sourceKey: string + sourceSeq: number + createdAt: number + agent: string + model: string | null + kind: UsageEventKind + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number +} + +type UsageEventRow = { + session_id: string + source_key: string + source_seq: number + created_at: number + agent: string + model: string | null + kind: UsageEventKind + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_creation_tokens: number +} + +function toUsageEvent(row: UsageEventRow): UsageEvent { + return { + sessionId: row.session_id, + sourceKey: row.source_key, + sourceSeq: row.source_seq, + createdAt: row.created_at, + agent: row.agent, + model: row.model, + kind: row.kind, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + cacheReadTokens: row.cache_read_tokens, + cacheCreationTokens: row.cache_creation_tokens + } +} + +export function upsertUsageEvents(db: Database, events: UsageEvent[]): void { + if (events.length === 0) return + + db.transaction(() => { + const statement = db.prepare(` + INSERT INTO usage_events ( + session_id, + source_key, + source_seq, + created_at, + agent, + model, + kind, + input_tokens, + output_tokens, + cache_read_tokens, + cache_creation_tokens + ) VALUES ( + @session_id, + @source_key, + @source_seq, + @created_at, + @agent, + @model, + @kind, + @input_tokens, + @output_tokens, + @cache_read_tokens, + @cache_creation_tokens + ) + ON CONFLICT(session_id, source_key) + DO UPDATE SET + source_seq = excluded.source_seq, + created_at = excluded.created_at, + agent = excluded.agent, + model = excluded.model, + kind = excluded.kind, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_read_tokens = excluded.cache_read_tokens, + cache_creation_tokens = excluded.cache_creation_tokens + `) + + for (const event of events) { + statement.run({ + session_id: event.sessionId, + source_key: event.sourceKey, + source_seq: event.sourceSeq, + created_at: event.createdAt, + agent: event.agent, + model: event.model, + kind: event.kind, + input_tokens: event.inputTokens, + output_tokens: event.outputTokens, + cache_read_tokens: event.cacheReadTokens, + cache_creation_tokens: event.cacheCreationTokens + }) + } + })() +} + +export function getUsageEvents(db: Database, sessionIds: string[]): UsageEvent[] { + if (sessionIds.length === 0) return [] + + const placeholders = sessionIds.map(() => '?').join(', ') + const rows = db.prepare(` + SELECT + session_id, + source_key, + source_seq, + created_at, + agent, + model, + kind, + input_tokens, + output_tokens, + cache_read_tokens, + cache_creation_tokens + FROM usage_events + WHERE session_id IN (${placeholders}) + ORDER BY created_at ASC, source_seq ASC + `).all(...sessionIds) as UsageEventRow[] + + return rows.map(toUsageEvent) +} + +export function getMaxUsageSourceSeqBySession(db: Database, sessionIds: string[]): Map { + if (sessionIds.length === 0) return new Map() + + const placeholders = sessionIds.map(() => '?').join(', ') + const rows = db.prepare(` + SELECT session_id, MAX(source_seq) AS max_source_seq + FROM usage_events + WHERE session_id IN (${placeholders}) + GROUP BY session_id + `).all(...sessionIds) as Array<{ session_id: string; max_source_seq: number | null }> + + return new Map(rows.map((row) => [row.session_id, row.max_source_seq ?? 0])) +} diff --git a/hub/src/store/usageStore.ts b/hub/src/store/usageStore.ts new file mode 100644 index 00000000..cfc11e1e --- /dev/null +++ b/hub/src/store/usageStore.ts @@ -0,0 +1,24 @@ +import type { Database } from 'bun:sqlite' + +import { + getMaxUsageSourceSeqBySession, + getUsageEvents, + upsertUsageEvents, + type UsageEvent +} from './usage' + +export class UsageStore { + constructor(private readonly db: Database) {} + + upsertEvents(events: UsageEvent[]): void { + upsertUsageEvents(this.db, events) + } + + getEvents(sessionIds: string[]): UsageEvent[] { + return getUsageEvents(this.db, sessionIds) + } + + getMaxSourceSeqBySession(sessionIds: string[]): Map { + return getMaxUsageSourceSeqBySession(this.db, sessionIds) + } +} diff --git a/hub/src/sync/usageService.test.ts b/hub/src/sync/usageService.test.ts new file mode 100644 index 00000000..f7627376 --- /dev/null +++ b/hub/src/sync/usageService.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { getUsageSummary } from './usageService' + +function addAgentMessage(store: Store, sessionId: string, content: unknown): void { + store.messages.addMessage(sessionId, { role: 'agent', content }) +} + +describe('usage service', () => { + it('deduplicates Claude stream fragments and diffs Codex cumulative snapshots', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession( + 'usage-test', + { path: '/tmp', host: 'test', flavor: 'codex' }, + null, + 'default', + 'test-model' + ) + + addAgentMessage(store, session.id, { + type: 'output', + data: { + type: 'assistant', + message: { id: 'claude-message', model: 'claude-test', usage: { input_tokens: 10, output_tokens: 2 } } + } + }) + addAgentMessage(store, session.id, { + type: 'output', + data: { + type: 'assistant', + message: { id: 'claude-message', model: 'claude-test', usage: { input_tokens: 12, output_tokens: 3 } } + } + }) + 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 } } } + }) + 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 } } } + }) + + 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) + store.close() + }) +}) diff --git a/hub/src/sync/usageService.ts b/hub/src/sync/usageService.ts new file mode 100644 index 00000000..fa92f39b --- /dev/null +++ b/hub/src/sync/usageService.ts @@ -0,0 +1,232 @@ +import type { UsageSummaryBucket, UsageSummaryResponse } from '@hapi/protocol/apiTypes' +import type { StoredMessage, StoredSession } from '../store' +import type { UsageEvent } from '../store/usage' +import type { Store } from '../store' + +type RecordValue = Record + +function asRecord(value: unknown): RecordValue | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as RecordValue + : null +} + +function asCount(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : null +} + +function firstCount(record: RecordValue, ...keys: string[]): number { + for (const key of keys) { + const value = asCount(record[key]) + if (value !== null) return value + } + return 0 +} + +function sessionAgent(session: StoredSession): string { + const metadata = asRecord(session.metadata) + const flavor = metadata?.flavor + return typeof flavor === 'string' && flavor.trim() ? flavor.trim() : 'unknown' +} + +function sessionModel(session: StoredSession): string | null { + return typeof session.model === 'string' && session.model.trim() ? session.model.trim() : null +} + +function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageEvent | null { + const envelope = asRecord(message.content) + if (envelope?.role !== 'agent') return null + + const payload = asRecord(envelope.content) + if (!payload) return null + const data = asRecord(payload.data) + if (!data) return null + + // Claude stream-json/SDK messages. A stream emits several updates for one + // assistant message, so the provider's message id is the stable upsert key. + if (payload.type === 'output' && data.type === 'assistant') { + const assistant = asRecord(data.message) + const usage = asRecord(assistant?.usage) + if (!usage) return null + const inputTokens = firstCount(usage, 'input_tokens', 'inputTokens') + const outputTokens = firstCount(usage, 'output_tokens', 'outputTokens') + const cacheReadTokens = firstCount(usage, 'cache_read_input_tokens', 'cacheReadTokens', 'cachedInputTokens') + const cacheCreationTokens = firstCount(usage, 'cache_creation_input_tokens', 'cacheCreationTokens', 'cacheWriteInputTokens') + if (inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) return null + const providerId = typeof assistant?.id === 'string' ? assistant.id : message.id + const model = typeof assistant?.model === 'string' && assistant.model.trim() + ? assistant.model.trim() + : sessionModel(session) + return { + sessionId: session.id, + sourceKey: `claude|${providerId}`, + sourceSeq: message.seq, + createdAt: message.createdAt, + agent: 'claude', + model, + kind: 'delta', + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens + } + } + + // Codex and ACP-compatible backends forward token_count snapshots. The + // `total` object is cumulative for a thread; aggregation below diffs it. + if (data.type === 'token_count' || data.type === 'usage') { + const info = asRecord(data.info) ?? data + const total = asRecord(info.total) ?? info + 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 threadId = typeof data.threadId === 'string' + ? data.threadId + : typeof data.thread_id === 'string' + ? data.thread_id + : session.id + const scope = typeof data.scopeRole === 'string' + ? data.scopeRole + : typeof data.scope_role === 'string' + ? data.scope_role + : 'parent' + const agent = sessionAgent(session) + return { + sessionId: session.id, + sourceKey: `cumulative|${threadId}|${scope}|${message.id}`, + sourceSeq: message.seq, + createdAt: message.createdAt, + agent, + model: sessionModel(session), + kind: 'cumulative', + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens + } + } + + return null +} + +function collectUsageEvents(store: Store, sessions: StoredSession[]): void { + const events: UsageEvent[] = [] + const maxSourceSeq = store.usage.getMaxSourceSeqBySession(sessions.map((session) => session.id)) + for (const session of sessions) { + const afterSeq = maxSourceSeq.get(session.id) ?? 0 + const messages = store.messages.getAllMessages(session.id) + for (const message of messages) { + if (afterSeq > 0 && message.seq <= afterSeq) continue + const event = parseUsageEvent(session, message) + if (event) events.push(event) + } + } + store.usage.upsertEvents(events) +} + +type Totals = Omit + +function emptyTotals(): Totals { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + requests: 0 + } +} + +function addTotals(target: Totals, inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number): void { + target.inputTokens += inputTokens + target.outputTokens += outputTokens + target.cacheReadTokens += cacheReadTokens + target.cacheCreationTokens += cacheCreationTokens + // 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.requests += 1 +} + +function toBucket(key: string, totals: Totals): UsageSummaryBucket { + return { key, ...totals } +} + +function dayKey(timestamp: number): string { + return new Date(timestamp).toISOString().slice(0, 10) +} + +export function getUsageSummary(store: Store, namespace: string, range: string | undefined): UsageSummaryResponse { + const sessions = store.sessions.getSessionsByNamespace(namespace) + // This is intentionally lazy. Existing HAPI databases have no usage table; + // the first dashboard request backfills history, while later requests only + // update the idempotent event rows. + collectUsageEvents(store, sessions) + + const now = Date.now() + const days = range === '30d' ? 30 : range === 'all' ? null : 7 + const from = days === null ? null : now - days * 24 * 60 * 60 * 1000 + const sessionIds = new Set(sessions.map((session) => session.id)) + const events = store.usage.getEvents(Array.from(sessionIds)) + const isInRange = (event: UsageEvent) => (from === null || event.createdAt >= from) && event.createdAt <= now + + const totals = emptyTotals() + const daily = new Map() + const byAgent = new Map() + const byModel = new Map() + const sessionsWithUsage = new Set() + const cumulativePrevious = new Map() + + for (const event of events) { + let inputTokens = event.inputTokens + let outputTokens = event.outputTokens + let cacheReadTokens = event.cacheReadTokens + let cacheCreationTokens = event.cacheCreationTokens + if (event.kind === 'cumulative') { + const streamKey = event.sourceKey.split('|').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 + } + cumulativePrevious.set(streamKey, [event.inputTokens, event.outputTokens, event.cacheReadTokens, event.cacheCreationTokens]) + } + if (!isInRange(event) || inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens <= 0) continue + const normalizedInputTokens = event.agent === 'claude' + ? inputTokens + cacheReadTokens + cacheCreationTokens + : inputTokens + addTotals(totals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) + const dailyTotals = daily.get(dayKey(event.createdAt)) ?? emptyTotals() + addTotals(dailyTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) + daily.set(dayKey(event.createdAt), dailyTotals) + const agentTotals = byAgent.get(event.agent) ?? emptyTotals() + addTotals(agentTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) + byAgent.set(event.agent, agentTotals) + const modelKey = event.model ?? 'unknown' + const modelTotals = byModel.get(modelKey) ?? emptyTotals() + addTotals(modelTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) + byModel.set(modelKey, modelTotals) + sessionsWithUsage.add(event.sessionId) + } + + const sortBuckets = (values: Map): UsageSummaryBucket[] => Array.from(values.entries()) + .map(([key, value]) => toBucket(key, value)) + .sort((a, b) => b.totalTokens - a.totalTokens) + + return { + range: { from, to: now }, + totals: { ...totals, sessions: sessionsWithUsage.size }, + daily: Array.from(daily.entries()) + .map(([key, value]) => toBucket(key, value)) + .sort((a, b) => a.key.localeCompare(b.key)), + byAgent: sortBuckets(byAgent), + byModel: sortBuckets(byModel), + updatedAt: now + } +} diff --git a/hub/src/web/routes/usage.ts b/hub/src/web/routes/usage.ts new file mode 100644 index 00000000..73c6fc17 --- /dev/null +++ b/hub/src/web/routes/usage.ts @@ -0,0 +1,21 @@ +import { Hono } from 'hono' +import type { UsageSummaryResponse } from '@hapi/protocol/apiTypes' +import type { WebAppEnv } from '../middleware/auth' +import type { Store } from '../../store' +import { getUsageSummary } from '../../sync/usageService' + +export function createUsageRoutes(store: Store): Hono { + const app = new Hono() + + app.get('/usage/summary', (c) => { + if (c.get('namespace') !== 'default') { + return c.json({ error: 'Usage summary is only available to the hub owner' }, 403) + } + const range = c.req.query('range') + const response: UsageSummaryResponse = getUsageSummary(store, c.get('namespace'), range) + c.header('Cache-Control', 'no-store') + return c.json(response) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 72877ddf..2021d475 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -19,6 +19,7 @@ import { createMessagesRoutes } from './routes/messages' import { createPermissionsRoutes } from './routes/permissions' import { createMachinesRoutes } from './routes/machines' import { createStorageRoutes } from './routes/storage' +import { createUsageRoutes } from './routes/usage' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' @@ -252,6 +253,7 @@ function createWebApp(options: { app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createStorageRoutes(configuration.dbPath)) + app.route('/api', createUsageRoutes(options.store)) app.route('/api', createGitRoutes(options.getSyncEngine)) // 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。 app.route('/api', createCodexDesktopRoutes({ diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index bf4d3355..425d1543 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -691,3 +691,33 @@ export type SqliteStorageUsageResponse = { shmBytes: number totalBytes: number } + +export type UsageSummaryBucket = { + key: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + totalTokens: number + requests: number +} + +export type UsageSummaryResponse = { + range: { + from: number | null + to: number | null + } + totals: { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + totalTokens: number + requests: number + sessions: number + } + daily: Array + byAgent: UsageSummaryBucket[] + byModel: UsageSummaryBucket[] + updatedAt: number +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c1a910e5..0f9beedd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -43,6 +43,7 @@ import type { QueuedStateResponse, ReopenSessionResponse, SqliteStorageUsageResponse, + UsageSummaryResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor } from '@hapi/protocol' @@ -630,6 +631,10 @@ export class ApiClient { return await this.request('/api/storage/sqlite') } + async getUsageSummary(range: '7d' | '30d' | 'all' = '7d'): Promise { + return await this.request(`/api/usage/summary?range=${encodeURIComponent(range)}`) + } + async listMachineDirectory( machineId: string, path: string diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 4c30da81..f45fbd91 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -610,6 +610,33 @@ function SessionChatInner(props: SessionChatProps) { ) const agentFlavor = props.session.metadata?.flavor ?? null const controlledByUser = props.session.agentState?.controlledByUser === true + const [claudeCustomModels, setClaudeCustomModels] = useState([]) + useEffect(() => { + if (agentFlavor !== 'claude') { + setClaudeCustomModels([]) + return + } + + let cancelled = false + try { + props.api.getClaudeCustomModels().then((result) => { + if (!cancelled) { + setClaudeCustomModels(Array.isArray(result.models) ? result.models : []) + } + }).catch(() => { + // Custom models are optional; keep the built-in Claude presets. + }) + } catch { + // Partial API clients (tests, older hubs) may not expose this method. + } + return () => { + cancelled = true + } + }, [agentFlavor, props.api]) + const claudeModelOptions = useMemo( + () => claudeCustomModels.map((model) => ({ value: model, label: model })), + [claudeCustomModels] + ) const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser const codexModelsState = useCodexModels({ api: props.api, @@ -1440,7 +1467,9 @@ function SessionChatInner(props: SessionChatProps) { effort={props.session.effort} agentFlavor={agentFlavor} availableModelOptions={ - agentFlavor === 'codex' + agentFlavor === 'claude' + ? claudeModelOptions + : agentFlavor === 'codex' ? codexModelOptions : agentFlavor === 'cursor' ? ( diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 7fb3bf07..ea809c01 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -34,6 +34,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { voice: t('settings.hub.voice.summary'), machines: t('settings.hub.machines.summary'), storage: t('settings.storage.summary'), + usage: t('settings.usage.summary'), about: `v${__APP_VERSION__}`, } const visibleCategories = settingsCategories.filter((category) => category.id !== 'storage' || getNamespace(token) === 'default') diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 9e717c97..6a731ab4 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -641,6 +641,26 @@ export default { 'settings.storage.path': 'Path', 'settings.storage.refresh': 'Refresh', '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.range.label': 'Usage range', + 'settings.usage.range.7d': '7 days', + 'settings.usage.range.30d': '30 days', + 'settings.usage.range.all': 'All time', + '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.input': 'Input tokens (includes cache)', + 'settings.usage.output': 'Output tokens', + 'settings.usage.cacheRead': 'Cache read (included)', + 'settings.usage.cacheCreation': 'Cache creation (included)', + 'settings.usage.requests': 'Requests', + 'settings.usage.daily.title': 'Daily trend', + 'settings.usage.agent.title': 'By agent', + 'settings.usage.model.title': 'By model', + 'settings.usage.sessions': '{count} sessions with usage', 'settings.general.description': 'Language, companion pairing, and general application preferences.', 'settings.language.title': 'Language', 'settings.language.label': 'Language', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2bfa8bc6..2640329c 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -645,6 +645,26 @@ export default { 'settings.storage.path': '路径', 'settings.storage.refresh': '刷新', 'settings.storage.refreshing': '正在刷新…', + 'settings.usage.title': 'Token 用量', + 'settings.usage.summary': 'Token 用量看板', + 'settings.usage.description': '统计 HAPI 会话记录的 Token 消耗,不包含费用估算。', + 'settings.usage.range.label': '用量范围', + 'settings.usage.range.7d': '近 7 天', + 'settings.usage.range.30d': '近 30 天', + 'settings.usage.range.all': '全部时间', + 'settings.usage.loading': '正在加载 Token 用量…', + 'settings.usage.error': '无法加载 Token 用量', + 'settings.usage.empty': '此范围内没有记录到 Token 用量。', + 'settings.usage.total': '总 Token(输入 + 输出)', + 'settings.usage.input': '输入 Token(含缓存)', + 'settings.usage.output': '输出 Token', + 'settings.usage.cacheRead': '缓存命中(已计入)', + 'settings.usage.cacheCreation': '缓存创建(已计入)', + 'settings.usage.requests': '请求数', + 'settings.usage.daily.title': '每日趋势', + 'settings.usage.agent.title': '按 Agent', + 'settings.usage.model.title': '按模型', + 'settings.usage.sessions': '{count} 个会话有用量记录', 'settings.general.description': '语言、伴侣应用配对和通用应用偏好。', 'settings.language.title': '语言', 'settings.language.label': '语言', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index bd601720..ec40c254 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -4,6 +4,7 @@ export const queryKeys = { messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, sqliteStorage: ['sqlite-storage'] as const, + usageSummary: (range: string) => ['usage-summary', range] as const, machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, diff --git a/web/src/router.tsx b/web/src/router.tsx index 085f9091..c6f2fc0e 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -60,6 +60,7 @@ import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced' import SettingsMachinesPage from '@/routes/settings/machines' import SettingsAboutPage from '@/routes/settings/about' import SettingsStoragePage from '@/routes/settings/storage' +import SettingsUsagePage from '@/routes/settings/usage' import SharePage from '@/routes/share' import { setSharePendingTransfer } from '@/lib/sharePendingState' import { deleteShareTransfer } from '@/lib/shareTransfer' @@ -1146,6 +1147,12 @@ const settingsStorageRoute = createRoute({ component: SettingsStoragePage, }) +const settingsUsageRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'usage', + component: SettingsUsagePage, +}) + // Web Share Target landing route. Service worker (`web/src/sw.ts`) // intercepts the manifest's `POST /share` and 303-redirects here with an // IDB transfer id. `error=ingest` is set when the SW failed to write IDB. @@ -1187,6 +1194,7 @@ export const routeTree = rootRoute.addChildren([ settingsVoiceAdvancedRoute, settingsMachinesRoute, settingsStorageRoute, + settingsUsageRoute, settingsAboutRoute, ]), shareRoute, diff --git a/web/src/routes/settings/categories.ts b/web/src/routes/settings/categories.ts index d0370ef2..793efed4 100644 --- a/web/src/routes/settings/categories.ts +++ b/web/src/routes/settings/categories.ts @@ -5,6 +5,7 @@ export const settingsCategories = [ { id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' }, { id: 'machines', path: '/settings/machines', titleKey: 'settings.machines.title' }, { id: 'storage', path: '/settings/storage', titleKey: 'settings.storage.title' }, + { id: 'usage', path: '/settings/usage', titleKey: 'settings.usage.title' }, { id: 'about', path: '/settings/about', titleKey: 'settings.about.title' }, ] as const diff --git a/web/src/routes/settings/usage.tsx b/web/src/routes/settings/usage.tsx new file mode 100644 index 00000000..74b0b984 --- /dev/null +++ b/web/src/routes/settings/usage.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { UsageSummaryBucket } from '@hapi/protocol/apiTypes' +import { SettingsPageContent, SettingsRow, SettingsSection } from '@/components/settings/SettingsPrimitives' +import { useAppContext } from '@/lib/app-context' +import { queryKeys } from '@/lib/query-keys' +import { useTranslation } from '@/lib/use-translation' + +type UsageRange = '7d' | '30d' | 'all' + +function formatTokens(value: number): string { + if (value < 1000) return value.toLocaleString() + if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}K` + if (value < 1_000_000_000) return `${(value / 1_000_000).toFixed(value < 10_000_000 ? 1 : 0)}M` + return `${(value / 1_000_000_000).toFixed(1)}B` +} + +function UsageBarList(props: { rows: UsageSummaryBucket[]; empty: string }) { + const max = props.rows[0]?.totalTokens ?? 0 + if (props.rows.length === 0) return
{props.empty}
+ return ( +
+ {props.rows.slice(0, 8).map((row) => ( +
+
+ {row.key} + {formatTokens(row.totalTokens)} +
+
+
0 ? Math.max(2, (row.totalTokens / max) * 100) : 0}%` }} /> +
+
+ {row.requests.toLocaleString()} requests · {formatTokens(row.inputTokens)} in · {formatTokens(row.outputTokens)} out +
+
+ ))} +
+ ) +} + +export default function SettingsUsagePage() { + const { api } = useAppContext() + const { t } = useTranslation() + const [range, setRange] = useState('7d') + const query = useQuery({ + queryKey: queryKeys.usageSummary(range), + queryFn: async () => { + if (!api) throw new Error('API unavailable') + return await api.getUsageSummary(range) + }, + enabled: Boolean(api), + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: false + }) + const maxDaily = useMemo(() => Math.max(...(query.data?.daily.map((row) => row.totalTokens) ?? [0]), 1), [query.data?.daily]) + + return ( + +
+ {(['7d', '30d', 'all'] as const).map((option) => ( + + ))} +
+ + {query.isLoading ? : null} + {query.error ? : null} + {query.data ? ( + <> +
+ {[ + ['settings.usage.total', query.data.totals.totalTokens], + ['settings.usage.input', query.data.totals.inputTokens], + ['settings.usage.output', query.data.totals.outputTokens], + ['settings.usage.cacheRead', query.data.totals.cacheReadTokens], + ['settings.usage.cacheCreation', query.data.totals.cacheCreationTokens], + ['settings.usage.requests', query.data.totals.requests] + ].map(([label, value]) => ( +
+
{t(label as string)}
+
{typeof value === 'number' ? formatTokens(value) : value}
+
+ ))} +
+ + {query.data.daily.length === 0 ?
{t('settings.usage.empty')}
: ( +
+ {query.data.daily.map((row) => ( +
+ {row.key} +
+ {formatTokens(row.totalTokens)} +
+ ))} +
+ )} + +
+ + + + + + +
+
+ {t('settings.usage.sessions', { count: query.data.totals.sessions })} +
+ + ) : null} + + ) +}