Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- [2026-10-30] the login daemon runs at interactive priority, so the dashboard stops timing out
- [2026-10-28] analytics reads a per-minute rollup, so an open dashboard stops freezing the daemon
- [2026-10-14] the dashboard fits the terminal it runs in
- [2026-10-01] [mac app polish](https://github.com/RubricLab/tokenmaxx/commit/675f52cbbc798b7ee8b161fe3794c13bde9363c8)
- [2026-09-30] proxy refuses web pages
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.79"
"version": "0.0.81"
}
1 change: 1 addition & 0 deletions src/launch-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe('login startup files', () => {
},
KeepAlive: true,
Label: 'sh.tokenmaxx.daemon',
ProcessType: 'Interactive',
RunAtLoad: true,
WorkingDirectory: input.userDirectory
})
Expand Down
3 changes: 3 additions & 0 deletions src/launch-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export function launchAgentFiles(input: LaunchAgentOptions) {
ExitTimeOut: 10,
KeepAlive: true,
Label: label,
// Without this launchd runs the daemon at utility QoS on efficiency cores,
// and every dashboard request and proxied call waits several times longer.
ProcessType: 'Interactive',
ProgramArguments: [executable, 'daemon', 'run'],
RunAtLoad: true,
StandardErrorPath: logPath,
Expand Down
97 changes: 97 additions & 0 deletions src/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,100 @@ describe('a database with rows this build cannot read', () => {
store.close()
})
})

describe('token analytics', () => {
const day = 24 * 3_600_000
const now = Date.UTC(2026, 9, 28, 12, 0, 0)
const accountId = '00000000-0000-4000-8000-000000000401'
const timeframes = [
{ key: '1h', ms: 3_600_000 },
{ key: '7d', ms: 7 * day },
{ key: '31d', ms: 31 * day }
]
const events = Array.from({ length: 400 }, (_, index) => ({
accountId: index % 3 === 0 ? null : accountId,
at: now - ((index * 7_919_993) % (30 * day)),
cacheCreationTokens: index % 5,
cacheReadTokens: index * 3,
inputTokens: 100 + index,
model: index % 4 === 0 ? null : `model-${index % 2}`,
outputTokens: index * 2,
provider: index % 2 === 0 ? ('anthropic' as const) : ('openai' as const)
}))
const sorted = <Row extends { model: string; provider?: string }>(rows: Row[]) =>
[...rows].sort((a, b) => `${a.provider}${a.model}`.localeCompare(`${b.provider}${b.model}`))

test('long windows read the minute rollup and keep the same totals', () => {
const store = createStateStore(
join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite')
)
for (const event of events) {
store.recordTokenEvent(event)
}
const [hour, week, month] = store.tokenAnalytics(now, timeframes)
const inWindow = (ms: number) => events.filter(event => event.at >= now - ms)
const total = (list: typeof events) =>
list.reduce(
(sum, event) =>
sum +
event.inputTokens +
event.outputTokens +
event.cacheReadTokens +
event.cacheCreationTokens,
0
)
expect(hour?.buckets.reduce((a, b) => a + b, 0)).toBe(total(inWindow(3_600_000)))
expect(week?.buckets.reduce((a, b) => a + b, 0)).toBe(total(inWindow(7 * day)))
expect(month?.buckets.reduce((a, b) => a + b, 0)).toBe(total(inWindow(31 * day)))
expect(sorted(month?.byModel ?? []).map(row => row.model)).toContain('unknown')
expect((month?.byModel ?? []).reduce((sum, row) => sum + row.input, 0)).toBe(
events.reduce((sum, event) => sum + event.inputTokens, 0)
)
expect(
store.accountTokens(accountId, now - 31 * day).reduce((sum, row) => sum + row.output, 0)
).toBe(
events
.filter(event => event.accountId === accountId)
.reduce((sum, event) => sum + event.outputTokens, 0)
)
store.close()
})

test('an existing database backfills the rollup from its raw events', () => {
const path = join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite')
const seed = createStateStore(path)
for (const event of events) {
seed.recordTokenEvent(event)
}
const expected = seed.tokenAnalytics(now, timeframes)
seed.close()

const database = new Database(path)
database.exec('DROP TABLE token_minutes')
database.close()

const store = createStateStore(path)
const actual = store.tokenAnalytics(now, timeframes)
expect(actual.map(frame => frame.buckets)).toEqual(expected.map(frame => frame.buckets))
expect(actual.map(frame => sorted(frame.byModel))).toEqual(
expected.map(frame => sorted(frame.byModel))
)
store.close()
})

test('minutes older than the retention window are pruned with their events', () => {
const path = join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite')
const store = createStateStore(path)
const old = { ...(events[1] as (typeof events)[number]), at: now - 40 * day }
store.recordTokenEvent(old)
store.recordTokenEvent({ ...old, at: now })
store.close()

const database = new Database(path)
const count = (table: string) =>
database.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n
expect(count('token_events')).toBe(1)
expect(count('token_minutes')).toBe(1)
database.close()
})
})
124 changes: 106 additions & 18 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ type PersistedSchema<Type> = { parse(value: unknown): Type }

const maxTokenEventAgeMs = 31 * 24 * 60 * 60 * 1000
const tokenBucketCount = 120
const tokenMinuteMs = 60_000
// Charts whose buckets are at least this wide read the per-minute rollup. A minute
// placed at its start shifts at most 10% of a bucket, and long windows stop
// scanning every raw event on the daemon's only thread.
const rollupMinBucketMs = 10 * tokenMinuteMs

export interface TokenTimeframeAggregate {
key: string
Expand Down Expand Up @@ -144,6 +149,43 @@ function migratePolicyDefaults(database: Database): void {
}
}

// One row per minute, provider, account and model, kept in step with token_events.
// Empty strings stand in for a missing account or model so the key stays unique.
function migrateTokenMinutes(database: Database): void {
const exists =
database
.query<{ name: string }, []>(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'token_minutes'"
)
.get() !== null
if (exists) {
return
}
database.transaction(() => {
database.exec(`
CREATE TABLE token_minutes (
minute INTEGER NOT NULL,
provider TEXT NOT NULL,
account_id TEXT NOT NULL,
model TEXT NOT NULL,
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 (minute, provider, account_id, model)
) WITHOUT ROWID;
CREATE INDEX token_minutes_account ON token_minutes(account_id, minute);
`)
database
.query(
`INSERT INTO token_minutes (minute, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens)
SELECT (at / ?) * ?, provider, COALESCE(account_id, ''), COALESCE(model, ''), SUM(input_tokens), SUM(output_tokens), SUM(cache_read_tokens), SUM(cache_creation_tokens)
FROM token_events GROUP BY 1, 2, 3, 4`
)
.run(tokenMinuteMs, tokenMinuteMs)
})()
}

function migrate(database: Database): void {
database.exec('PRAGMA journal_mode = WAL')
database.exec('PRAGMA foreign_keys = ON')
Expand Down Expand Up @@ -215,6 +257,7 @@ function migrate(database: Database): void {
'ALTER TABLE token_events ADD COLUMN cache_creation_tokens INTEGER NOT NULL DEFAULT 0'
)
}
migrateTokenMinutes(database)
const requiresLabelMigration = !accountColumns.has('label')
const requiresExternalAccountIdMigration = !accountColumns.has('external_account_id')
const requiresExternalUserIdMigration = !accountColumns.has('external_user_id')
Expand Down Expand Up @@ -509,21 +552,41 @@ export function createStateStore(databasePath: string): StateStore {

function recordTokenEvent(event: TokenEvent): void {
const parsed = TokenEventSchema.parse(event)
database
.query(
'INSERT INTO token_events (at, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
.run(
parsed.at,
parsed.provider,
parsed.accountId,
parsed.model,
parsed.inputTokens,
parsed.outputTokens,
parsed.cacheReadTokens,
parsed.cacheCreationTokens
)
database.query('DELETE FROM token_events WHERE at < ?').run(parsed.at - maxTokenEventAgeMs)
const cutoff = parsed.at - maxTokenEventAgeMs
database.transaction(() => {
database
.query(
'INSERT INTO token_events (at, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
.run(
parsed.at,
parsed.provider,
parsed.accountId,
parsed.model,
parsed.inputTokens,
parsed.outputTokens,
parsed.cacheReadTokens,
parsed.cacheCreationTokens
)
database
.query(
'INSERT INTO token_minutes (minute, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (minute, provider, account_id, model) DO UPDATE SET input_tokens = input_tokens + excluded.input_tokens, output_tokens = output_tokens + excluded.output_tokens, cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens, cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens'
)
.run(
Math.floor(parsed.at / tokenMinuteMs) * tokenMinuteMs,
parsed.provider,
parsed.accountId ?? '',
parsed.model ?? '',
parsed.inputTokens,
parsed.outputTokens,
parsed.cacheReadTokens,
parsed.cacheCreationTokens
)
database.query('DELETE FROM token_events WHERE at < ?').run(cutoff)
database
.query('DELETE FROM token_minutes WHERE minute < ?')
.run(Math.floor(cutoff / tokenMinuteMs) * tokenMinuteMs)
})()
}

function tokenAnalytics(
Expand All @@ -549,15 +612,40 @@ export function createStateStore(databasePath: string): StateStore {
>(
'SELECT model, provider, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_events WHERE at >= ? AND at <= ? GROUP BY model, provider'
)
const minuteBucketQuery = database.query<
{ b: number; tokens: number },
[number, number, number, number]
>(
'SELECT CAST((minute - ?) / ? AS INTEGER) AS b, SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens) AS tokens FROM token_minutes WHERE minute >= ? AND minute <= ? GROUP BY b'
)
const minuteModelQuery = database.query<
{
model: string | null
provider: string
input: number
output: number
cached: number
cacheCreation: number
},
[number, number]
>(
"SELECT NULLIF(model, '') AS model, provider, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_minutes WHERE minute >= ? AND minute <= ? GROUP BY model, provider"
)
return timeframes.map(({ key, ms }) => {
const start = nowMillis - ms
const bucketMs = Math.max(1, Math.round(ms / tokenBucketCount))
const rollup = bucketMs >= rollupMinBucketMs
const buckets = new Array<number>(tokenBucketCount).fill(0)
for (const row of bucketQuery.all(start, bucketMs, start, nowMillis)) {
for (const row of (rollup ? minuteBucketQuery : bucketQuery).all(
start,
bucketMs,
start,
nowMillis
)) {
const index = Math.min(tokenBucketCount - 1, Math.max(0, row.b))
buckets[index] = (buckets[index] ?? 0) + row.tokens
}
const byModel = modelQuery.all(start, nowMillis).map(row => ({
const byModel = (rollup ? minuteModelQuery : modelQuery).all(start, nowMillis).map(row => ({
cacheCreation: row.cacheCreation,
cached: row.cached,
input: row.input,
Expand All @@ -581,7 +669,7 @@ export function createStateStore(databasePath: string): StateStore {
},
[string, number]
>(
'SELECT model, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_events WHERE account_id = ? AND at >= ? GROUP BY model'
"SELECT NULLIF(model, '') AS model, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_minutes WHERE account_id = ? AND minute >= ? GROUP BY model"
)
.all(accountId, startMillis)
.map(row => ({ ...row, model: row.model ?? 'unknown' }))
Expand Down