feat(hub,web): add token usage dashboard

Track provider usage events with cache-aware token accounting and expose a settings dashboard.\n\nCo-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:52:05 +08:00
parent 6483985d2f
commit b3c49e09f1
19 changed files with 775 additions and 9 deletions
+52 -2
View File
@@ -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<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(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()
+3 -3
View File
@@ -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()
+147
View File
@@ -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<string, number> {
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]))
}
+24
View File
@@ -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<string, number> {
return getMaxUsageSourceSeqBySession(this.db, sessionIds)
}
}
+53
View File
@@ -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()
})
})
+232
View File
@@ -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<string, unknown>
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<UsageSummaryBucket, 'key'>
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<string, Totals>()
const byAgent = new Map<string, Totals>()
const byModel = new Map<string, Totals>()
const sessionsWithUsage = new Set<string>()
const cumulativePrevious = new Map<string, [number, number, number, number]>()
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<string, Totals>): 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
}
}
+21
View File
@@ -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<WebAppEnv> {
const app = new Hono<WebAppEnv>()
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
}
+2
View File
@@ -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({
+30
View File
@@ -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<UsageSummaryBucket & { key: string }>
byAgent: UsageSummaryBucket[]
byModel: UsageSummaryBucket[]
updatedAt: number
}
+5
View File
@@ -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<SqliteStorageUsageResponse>('/api/storage/sqlite')
}
async getUsageSummary(range: '7d' | '30d' | 'all' = '7d'): Promise<UsageSummaryResponse> {
return await this.request<UsageSummaryResponse>(`/api/usage/summary?range=${encodeURIComponent(range)}`)
}
async listMachineDirectory(
machineId: string,
path: string
+30 -1
View File
@@ -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<string[]>([])
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'
? (
@@ -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')
+20
View File
@@ -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',
+20
View File
@@ -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': '语言',
+1
View File
@@ -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,
+8
View File
@@ -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,
+1
View File
@@ -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
+122
View File
@@ -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 <div className="px-3 py-4 text-sm text-[var(--app-hint)]">{props.empty}</div>
return (
<div className="divide-y divide-[var(--app-divider)]">
{props.rows.slice(0, 8).map((row) => (
<div key={row.key} className="px-3 py-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate font-medium text-[var(--app-fg)]">{row.key}</span>
<span className="shrink-0 text-[var(--app-hint)]">{formatTokens(row.totalTokens)}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--app-subtle-bg)]">
<div className="h-full rounded-full bg-[var(--app-link)]" style={{ width: `${max > 0 ? Math.max(2, (row.totalTokens / max) * 100) : 0}%` }} />
</div>
<div className="mt-1 text-xs text-[var(--app-hint)]">
{row.requests.toLocaleString()} requests · {formatTokens(row.inputTokens)} in · {formatTokens(row.outputTokens)} out
</div>
</div>
))}
</div>
)
}
export default function SettingsUsagePage() {
const { api } = useAppContext()
const { t } = useTranslation()
const [range, setRange] = useState<UsageRange>('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 (
<SettingsPageContent description={t('settings.usage.description')}>
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label={t('settings.usage.range.label')}>
{(['7d', '30d', 'all'] as const).map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={range === option}
onClick={() => setRange(option)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${range === option ? 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)]' : 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'}`}
>
{t(`settings.usage.range.${option}`)}
</button>
))}
</div>
{query.isLoading ? <SettingsSection><SettingsRow label={t('settings.usage.loading')} /></SettingsSection> : null}
{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">
{[
['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]) => (
<div key={label} className="rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-3 shadow-sm">
<div className="text-xs text-[var(--app-hint)]">{t(label as string)}</div>
<div className="mt-1 text-xl font-semibold text-[var(--app-fg)]">{typeof value === 'number' ? formatTokens(value) : value}</div>
</div>
))}
</div>
<SettingsSection title={t('settings.usage.daily.title')}>
{query.data.daily.length === 0 ? <div className="px-3 py-4 text-sm text-[var(--app-hint)]">{t('settings.usage.empty')}</div> : (
<div className="space-y-3 px-3 py-4">
{query.data.daily.map((row) => (
<div key={row.key} className="grid grid-cols-[5.5rem_1fr_auto] items-center gap-2 text-xs">
<span className="text-[var(--app-hint)]">{row.key}</span>
<div className="h-2 overflow-hidden rounded-full bg-[var(--app-subtle-bg)]"><div className="h-full rounded-full bg-[var(--app-link)]" style={{ width: `${Math.max(2, (row.totalTokens / maxDaily) * 100)}%` }} /></div>
<span className="text-right font-medium text-[var(--app-fg)]">{formatTokens(row.totalTokens)}</span>
</div>
))}
</div>
)}
</SettingsSection>
<div className="grid gap-5 md:grid-cols-2">
<SettingsSection title={t('settings.usage.agent.title')}>
<UsageBarList rows={query.data.byAgent} empty={t('settings.usage.empty')} />
</SettingsSection>
<SettingsSection title={t('settings.usage.model.title')}>
<UsageBarList rows={query.data.byModel} empty={t('settings.usage.empty')} />
</SettingsSection>
</div>
<div className="text-xs text-[var(--app-hint)]">
{t('settings.usage.sessions', { count: query.data.totals.sessions })}
</div>
</>
) : null}
</SettingsPageContent>
)
}