From 88416f809076bcab2aaa0d61608fa277fb543388 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 15:49:01 -0400 Subject: [PATCH 1/9] api keys and extra usage, fixes #10 --- CHANGELOG.md | 3 +- package.json | 2 +- src/claude.test.ts | 37 ++++++++++- src/claude.ts | 144 +++++++++++++++++++++++++++++++++++++++++- src/cli.ts | 53 +++++++++++++--- src/codex.test.ts | 37 ++++++++++- src/codex.ts | 110 ++++++++++++++++++++++++++++++++ src/domain.ts | 36 +++++++++-- src/manager.ts | 10 +++ src/selection.test.ts | 37 +++++++++++ src/selection.ts | 9 +++ src/storage.ts | 23 +++++++ src/tui/dashboard.ts | 96 ++++++++++++++++++++++++++-- src/tui/fixtures.ts | 32 ++++++++++ 14 files changed, 603 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41911f6..3f9dd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -- [2026-07-22] alpha channel +- [2026-07-22] api keys and extra usage, fixes #10 +- [2026-07-22] [alpha channel](https://github.com/RubricLab/tokenmaxx/commit/5e106a78a47a6265b37dbd3713cbe17635458372) - [2026-07-21] follow claude's profile email rename - [2026-07-21] [failed logins say why](https://github.com/RubricLab/tokenmaxx/commit/e28f1472327450ca8e7640ca7ab5c3b945790a31) - [2026-07-20] [readme](https://github.com/RubricLab/tokenmaxx/commit/e2d9002286227126cb529961d6bad9ca4bc36f6c) diff --git a/package.json b/package.json index 2ffa1fe..c3d402d 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.53" + "version": "0.0.54" } diff --git a/src/claude.test.ts b/src/claude.test.ts index 4dfe541..1c4f135 100644 --- a/src/claude.test.ts +++ b/src/claude.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { type ClaudeOauth, refreshClaudeCredential, registerClaudeAccount } from './claude.ts' +import { + type ClaudeOauth, + claudeUpstream, + refreshClaudeCredential, + registerClaudeAccount +} from './claude.ts' import type { CredentialVault } from './vault.ts' function memoryVault(initial: Record): CredentialVault & { @@ -125,3 +130,33 @@ describe('refreshClaudeCredential', () => { expect(result.accessToken).toBe('rotated-access') }) }) + +describe('api key accounts', () => { + test('an api key account injects x-api-key and strips the oauth header', async () => { + const vault = memoryVault({ 'claude-key:1': 'sk-ant-test' }) + const injection = await claudeUpstream({ + account: { + auth: 'apiKey', + createdAt: '2026-07-01T00:00:00.000Z', + enabled: true, + externalAccountId: null, + externalUserId: null, + health: 'ready', + id: '00000000-0000-4000-8000-000000000201', + identity: 'work api key', + label: 'work api key', + onThreshold: 'switch', + plan: 'api', + profilePath: null, + provider: 'anthropic', + secretReference: 'claude-key:1', + updatedAt: '2026-07-01T00:00:00.000Z' + }, + forceRefresh: false, + vault + }) + expect(injection.baseUrl).toBe('https://api.anthropic.com') + expect(injection.headers['x-api-key']).toBe('sk-ant-test') + expect(injection.stripHeaders).toContain('authorization') + }) +}) diff --git a/src/claude.ts b/src/claude.ts index 8e040f9..4fec5bd 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { type Account, AccountEmailSchema, + type ExtraUsage, type FetchImplementation, type ProviderProbeResult, type UsageSnapshot, @@ -202,6 +203,69 @@ async function readClaudeCredential( return ClaudeOauthSchema.parse(JSON.parse(serialized)) } +async function readApiKey(vault: CredentialVault, reference: string): Promise { + const key = await vault.read(reference) + if (key === null) { + throw new ApplicationError('CREDENTIAL_MISSING', `Missing credential ${reference}`) + } + return key +} + +const anthropicVersion = '2023-06-01' + +async function validateAnthropicApiKey( + key: string, + fetchImplementation: FetchImplementation +): Promise { + const response = await fetchImplementation('https://api.anthropic.com/v1/models?limit=1', { + headers: { 'anthropic-version': anthropicVersion, 'x-api-key': key }, + signal: AbortSignal.timeout(10_000) + }) + if (response.status === 401 || response.status === 403) { + throw new ApplicationError('ACCESS_TOKEN_REJECTED', 'Anthropic rejected this API key') + } + if (!response.ok && response.status !== 429) { + throw new ApplicationError( + 'PROVIDER_UNREACHABLE', + `Anthropic API returned HTTP ${response.status}` + ) + } +} + +export async function registerClaudeApiKeyAccount(input: { + vault: CredentialVault + key: string + label: string + fetchImplementation?: FetchImplementation +}): Promise { + const key = input.key.trim() + if (key.length === 0) { + throw new ApplicationError('USAGE', 'The API key is empty') + } + await validateAnthropicApiKey(key, input.fetchImplementation ?? fetch) + const id = crypto.randomUUID() + const secretReference = `claude-key:${id}` + await input.vault.write(secretReference, key) + const now = new Date().toISOString() + return { + auth: 'apiKey', + createdAt: now, + enabled: true, + externalAccountId: null, + externalUserId: null, + health: 'ready', + id, + identity: input.label, + label: input.label, + onThreshold: 'switch', + plan: null, + profilePath: null, + provider: 'anthropic', + secretReference, + updatedAt: now + } +} + export async function refreshClaudeCredential(input: { reference: string vault: CredentialVault @@ -312,6 +376,7 @@ export async function registerClaudeAccount(input: { await input.vault.write(secretReference, JSON.stringify(credential)) const now = new Date().toISOString() return { + auth: 'oauth', createdAt: now, enabled: true, externalAccountId: profile.accountId, @@ -320,6 +385,7 @@ export async function registerClaudeAccount(input: { id, identity: email.data, label: email.data, + onThreshold: 'switch', plan: claudePlanTier(credential), profilePath: null, provider: 'anthropic', @@ -369,6 +435,14 @@ export async function claudeUpstream(input: { `${input.account.label} has no stored credential` ) } + if (input.account.auth === 'apiKey') { + return { + accountId: input.account.id, + baseUrl: upstreamFor('anthropic'), + headers: { 'x-api-key': await readApiKey(input.vault, reference) }, + stripHeaders: ['authorization'] + } + } let credential = await readClaudeCredential(input.vault, reference) const now = input.now ?? (() => Date.now()) const stale = credential.expiresAt - now() <= refreshMarginMilliseconds @@ -426,17 +500,67 @@ const LimitSchema = z }) .passthrough() +const MoneySchema = z + .object({ + amount_minor: z.number(), + currency: z.string().nullish(), + exponent: z.number().int().nullish() + }) + .passthrough() + +const ExtraUsageResponseSchema = z + .object({ + is_enabled: z.boolean().nullish(), + monthly_limit: z.number().nullish(), + spend_limit_reached: z.boolean().nullish(), + used_credits: z.number().nullish(), + utilization: z.number().nullish() + }) + .passthrough() + +const SpendResponseSchema = z + .object({ + balance: MoneySchema.nullish(), + limit: MoneySchema.nullish(), + used: MoneySchema.nullish() + }) + .passthrough() + const UsageResponseSchema = z .object({ + extra_usage: ExtraUsageResponseSchema.nullish(), five_hour: UsageWindowResponseSchema.nullish(), limits: z.array(LimitSchema).nullish(), seven_day: UsageWindowResponseSchema.nullish(), seven_day_oauth_apps: UsageWindowResponseSchema.nullish(), seven_day_opus: UsageWindowResponseSchema.nullish(), - seven_day_sonnet: UsageWindowResponseSchema.nullish() + seven_day_sonnet: UsageWindowResponseSchema.nullish(), + spend: SpendResponseSchema.nullish() }) .passthrough() +function moneyToUsd(money: z.infer | null | undefined): number | null { + if (money == null) { + return null + } + return money.amount_minor / 10 ** (money.exponent ?? 2) +} + +function claudeExtraUsage(body: z.infer): ExtraUsage | null { + const extra = body.extra_usage + if (extra == null) { + return null + } + return { + balanceUsd: moneyToUsd(body.spend?.balance), + enabled: extra.is_enabled === true, + exhausted: extra.spend_limit_reached === true, + limitUsd: moneyToUsd(body.spend?.limit), + spentUsd: moneyToUsd(body.spend?.used), + usedPercent: extra.utilization == null ? null : Math.min(100, normalizePercent(extra.utilization)) + } +} + function resetTimestamp(value: string | number | null | undefined): string | null { if (value == null) { return null @@ -559,11 +683,13 @@ async function fetchClaudeUsage(input: { } return { accountId: input.accountId, + extraUsage: claudeExtraUsage(body), hardLimitReached: windows.some(window => window.kind === 'hard' && window.usedPercent >= 100) || limits.some( limit => limit.severity != null && exhaustedSeverities.has(limit.severity.toLowerCase()) ), + measuredSpendUsd: null, observedAt: new Date().toISOString(), provider: 'anthropic', source: 'claudeUsageEndpoint', @@ -620,6 +746,22 @@ export async function probeClaude(input: { if (reference === null) { throw new ApplicationError('CREDENTIAL_MISSING', `${account.label} has no stored credential`) } + if (account.auth === 'apiKey') { + await validateAnthropicApiKey(await readApiKey(vault, reference), fetchImplementation) + return { + account: { ...account, health: 'ready', updatedAt: input.now().toISOString() }, + usage: { + accountId: account.id, + extraUsage: null, + hardLimitReached: false, + measuredSpendUsd: null, + observedAt: input.now().toISOString(), + provider: 'anthropic', + source: 'apiKeyProbe', + windows: [] + } + } + } const refresh = (staleAccessToken: string) => refreshClaudeCredential({ fetchImplementation, diff --git a/src/cli.ts b/src/cli.ts index a8d4272..2a15bf8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,8 +4,8 @@ import { type FileHandle, mkdir, open, readFile, rm, stat } from 'node:fs/promis import { homedir } from 'node:os' import { join } from 'node:path' import { z } from 'zod' -import { registerClaudeAccount } from './claude.ts' -import { registerCodexAccount } from './codex.ts' +import { registerClaudeAccount, registerClaudeApiKeyAccount } from './claude.ts' +import { registerCodexAccount, registerOpenAiApiKeyAccount } from './codex.ts' import { installClaudeConfig, installCodexConfig, @@ -233,7 +233,11 @@ function help(): string { `${head('Usage')} tokenmaxx [options] ${dim('run with no command for the dashboard')}`, '', head('Setup'), - row('login ', 'sign in an account · re-run to re-auth'), + row( + 'login ', + 'sign in an account · re-run to re-auth', + 'add --api-key to use an API key instead' + ), row('install', 'route codex & claude through tokenmaxx'), row('uninstall', 'restore your original config'), '', @@ -453,17 +457,43 @@ function assertCliInstalled(provider: ProviderId): void { } } +async function registerApiKeyAccount( + provider: 'openai' | 'anthropic', + keyArgument: string | undefined +): Promise { + if (process.stdin.isTTY !== true && keyArgument === undefined) { + throw new ApplicationError( + 'USAGE', + 'Pass the key inline in non-interactive shells: tokenmaxx login --api-key ' + ) + } + const key = keyArgument ?? prompt('Paste the API key:') ?? '' + const label = prompt('Name this account (shown in the dashboard):') ?? '' + if (label.trim().length === 0) { + throw new ApplicationError('USAGE', 'The account needs a name') + } + const vault = createMacOsKeychainVault() + return provider === 'openai' + ? registerOpenAiApiKeyAccount({ key, label: label.trim(), vault }) + : registerClaudeApiKeyAccount({ key, label: label.trim(), vault }) +} + async function login( context: ApplicationContext, - providerArgument: string | undefined + providerArgument: string | undefined, + options: { apiKey: boolean; apiKeyValue?: string } = { apiKey: false } ): Promise { if (providerArgument === undefined) { - throw new ApplicationError('USAGE', 'Usage: tokenmaxx login ') + throw new ApplicationError('USAGE', 'Usage: tokenmaxx login [--api-key [key]]') } const provider = providerFromCli(providerArgument) - assertCliInstalled(provider) + if (!options.apiKey) { + assertCliInstalled(provider) + } await ensureDaemon(context) - const authenticated = await registerIsolatedAccount(provider) + const authenticated = options.apiKey + ? await registerApiKeyAccount(provider, options.apiKeyValue) + : await registerIsolatedAccount(provider) const existing = context.store .listAccounts(provider) .find( @@ -827,9 +857,14 @@ export async function runCli(rawArguments: readonly string[]): Promise { case '-h': process.stdout.write(`${help()}\n`) return 0 - case 'login': - await login(context, arguments_[1]) + case 'login': { + const flagIndex = arguments_.indexOf('--api-key') + await login(context, arguments_[1], { + apiKey: flagIndex >= 0, + apiKeyValue: flagIndex >= 0 ? arguments_[flagIndex + 1] : undefined + }) return 0 + } case 'switch': await switchAccount(context, arguments_.slice(1)) return 0 diff --git a/src/codex.test.ts b/src/codex.test.ts index 7ab1aa2..45a4ba2 100644 --- a/src/codex.test.ts +++ b/src/codex.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { probeCodex, probeCodexResetCredits, redeemCodexResetCredit } from './codex.ts' +import { + codexUpstream, + probeCodex, + probeCodexResetCredits, + redeemCodexResetCredit +} from './codex.ts' import type { Account } from './domain.ts' import type { CredentialVault } from './vault.ts' @@ -40,6 +45,7 @@ const credential = JSON.stringify({ }) const account: Extract = { + auth: 'oauth', createdAt: '2026-07-01T00:00:00.000Z', enabled: true, externalAccountId: 'acct-1', @@ -48,6 +54,7 @@ const account: Extract = { id: '00000000-0000-4000-8000-000000000001', identity: 'dexter@rubriclabs.com', label: 'dexter@rubriclabs.com', + onThreshold: 'switch', plan: 'pro', profilePath: null, provider: 'openai', @@ -62,6 +69,14 @@ describe('codex reset credits on the wire', () => { fetchImplementation: async () => Response.json({ additional_rate_limits: [], + credits: { + approx_cloud_messages: [0, 0], + approx_local_messages: [0, 0], + balance: '12.5', + has_credits: true, + overage_limit_reached: false, + unlimited: false + }, rate_limit: { allowed: true, limit_reached: false, @@ -83,6 +98,26 @@ describe('codex reset credits on the wire', () => { applicable: 0, available: 3 }) + expect(result.usage.extraUsage).toEqual({ + balanceUsd: 12.5, + enabled: true, + exhausted: false, + limitUsd: null, + spentUsd: null, + usedPercent: null + }) + }) + + test('an api key account routes to the platform api with its key', async () => { + const vault = memoryVault({ 'codex-key:1': 'sk-test-123' }) + const injection = await codexUpstream({ + account: { ...account, auth: 'apiKey', secretReference: 'codex-key:1' }, + forceRefresh: false, + vault + }) + expect(injection.baseUrl).toBe('https://api.openai.com/v1') + expect(injection.headers.authorization).toBe('Bearer sk-test-123') + expect(injection.stripHeaders).toContain('chatgpt-account-id') }) test('the credits list keeps only available credits, soonest expiry first', async () => { diff --git a/src/codex.ts b/src/codex.ts index 78e331d..0772e08 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -187,6 +187,7 @@ export async function registerCodexAccount(input: { await input.vault.write(secretReference, JSON.stringify(auth)) const now = new Date().toISOString() return { + auth: 'oauth', createdAt: now, enabled: true, externalAccountId: identity.accountId, @@ -195,6 +196,7 @@ export async function registerCodexAccount(input: { id, identity: email.data, label: email.data, + onThreshold: 'switch', plan: identity.plan, profilePath: null, provider: 'openai', @@ -264,6 +266,66 @@ async function refreshCodexCredential(input: { }) } +async function readApiKey(vault: CredentialVault, reference: string): Promise { + const key = await vault.read(reference) + if (key === null) { + throw new ApplicationError('CREDENTIAL_MISSING', `Missing credential ${reference}`) + } + return key +} + +const openAiApiBase = 'https://api.openai.com/v1' + +async function validateOpenAiApiKey( + key: string, + fetchImplementation: FetchImplementation +): Promise { + const response = await fetchImplementation(`${openAiApiBase}/models`, { + headers: { Authorization: `Bearer ${key}` }, + signal: AbortSignal.timeout(10_000) + }) + if (response.status === 401 || response.status === 403) { + throw new ApplicationError('ACCESS_TOKEN_REJECTED', 'OpenAI rejected this API key') + } + if (!response.ok && response.status !== 429) { + throw new ApplicationError('PROVIDER_UNREACHABLE', `OpenAI API returned HTTP ${response.status}`) + } +} + +export async function registerOpenAiApiKeyAccount(input: { + vault: CredentialVault + key: string + label: string + fetchImplementation?: FetchImplementation +}): Promise { + const key = input.key.trim() + if (key.length === 0) { + throw new ApplicationError('USAGE', 'The API key is empty') + } + await validateOpenAiApiKey(key, input.fetchImplementation ?? fetch) + const id = crypto.randomUUID() + const secretReference = `codex-key:${id}` + await input.vault.write(secretReference, key) + const now = new Date().toISOString() + return { + auth: 'apiKey', + createdAt: now, + enabled: true, + externalAccountId: null, + externalUserId: null, + health: 'ready', + id, + identity: input.label, + label: input.label, + onThreshold: 'switch', + plan: null, + profilePath: null, + provider: 'openai', + secretReference, + updatedAt: now + } +} + const refreshMarginMilliseconds = 120_000 export async function codexUpstream(input: { @@ -274,6 +336,14 @@ export async function codexUpstream(input: { forceRefresh: boolean }): Promise { const reference = input.account.secretReference + if (input.account.auth === 'apiKey') { + return { + accountId: input.account.id, + baseUrl: openAiApiBase, + headers: { authorization: `Bearer ${await readApiKey(input.vault, reference)}` }, + stripHeaders: ['chatgpt-account-id'] + } + } let auth = await readCodexCredential(input.vault, reference) const now = input.now ?? (() => Date.now()) const expiresAt = codexIdentity(auth).accessExpiresAt @@ -324,6 +394,15 @@ const AdditionalLimitSchema = z const UsageResponseSchema = z .object({ additional_rate_limits: z.array(AdditionalLimitSchema).nullish(), + credits: z + .object({ + balance: z.union([z.string(), z.number()]).nullish(), + has_credits: z.boolean().nullish(), + overage_limit_reached: z.boolean().nullish(), + unlimited: z.boolean().nullish() + }) + .passthrough() + .nullish(), rate_limit: LimitDetailsSchema.nullish(), rate_limit_reached_type: z.unknown().nullish(), rate_limit_reset_credits: z @@ -394,8 +473,21 @@ function normalizeResponse(accountId: string, body: UsageResponse): UsageSnapsho additional.rate_limit ) } + const credits = body.credits + const balance = credits?.balance == null ? Number.NaN : Number(credits.balance) return { accountId, + extraUsage: + credits == null + ? null + : { + balanceUsd: Number.isFinite(balance) ? balance : null, + enabled: credits.has_credits === true || credits.unlimited === true, + exhausted: credits.overage_limit_reached === true, + limitUsd: null, + spentUsd: null, + usedPercent: null + }, hardLimitReached: body.rate_limit?.limit_reached === true || body.rate_limit?.allowed === false || @@ -404,6 +496,7 @@ function normalizeResponse(accountId: string, body: UsageResponse): UsageSnapsho additional => additional.rate_limit?.limit_reached === true || additional.rate_limit?.allowed === false ), + measuredSpendUsd: null, observedAt: new Date().toISOString(), provider: 'openai', resetCredits: @@ -560,6 +653,23 @@ export async function probeCodex(input: { now(): Date }): Promise { const { account, vault, fetchImplementation } = input + if (account.auth === 'apiKey') { + await validateOpenAiApiKey(await readApiKey(vault, account.secretReference), fetchImplementation) + return { + account: { ...account, health: 'ready', updatedAt: input.now().toISOString() }, + usage: { + accountId: account.id, + extraUsage: null, + hardLimitReached: false, + measuredSpendUsd: null, + observedAt: input.now().toISOString(), + provider: 'openai', + resetCredits: null, + source: 'apiKeyProbe', + windows: [] + } + } + } let credential = await readCodexCredential(vault, account.secretReference) let identity = codexIdentity(credential) if (account.externalAccountId !== null && account.externalAccountId !== identity.accountId) { diff --git a/src/domain.ts b/src/domain.ts index e282f5c..0677a20 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -9,6 +9,7 @@ export const ProviderIdSchema = z.enum(['openai', 'anthropic']) export type ProviderId = z.infer export const AccountEmailSchema = z.string().trim().toLowerCase().email() +const AccountNameSchema = z.string().trim().min(1) const HealthStateSchema = z.enum([ 'unchecked', @@ -24,13 +25,15 @@ const HealthStateSchema = z.enum([ ]) const AccountFieldsSchema = z.object({ + auth: z.enum(['oauth', 'apiKey']).default('oauth'), createdAt: z.iso.datetime(), enabled: z.boolean(), externalAccountId: z.string().trim().min(1).nullable(), health: HealthStateSchema, id: z.uuid(), - identity: AccountEmailSchema, - label: AccountEmailSchema, + identity: AccountNameSchema, + label: AccountNameSchema, + onThreshold: z.enum(['switch', 'spill']).default('switch'), plan: z.string().trim().min(1).nullish(), updatedAt: z.iso.datetime() }) @@ -51,9 +54,16 @@ export const AccountSchema = z }).strict() ]) .refine(account => account.label === account.identity, { - message: 'Account label must equal its authenticated email identity', + message: 'Account label must equal its identity', path: ['label'] }) + .refine( + account => account.auth === 'apiKey' || AccountEmailSchema.safeParse(account.identity).success, + { + message: 'Signed-in accounts are identified by their authenticated email', + path: ['identity'] + } + ) export type Account = z.infer const UsageWindowSchema = z @@ -82,15 +92,31 @@ export const ResetCreditCountsSchema = z .strict() export type ResetCreditCounts = z.infer +export const ExtraUsageSchema = z + .object({ + balanceUsd: z.number().nullable(), + enabled: z.boolean(), + exhausted: z.boolean(), + limitUsd: z.number().nullable(), + spentUsd: z.number().nullable(), + usedPercent: z.number().min(0).max(100).nullable() + }) + .strict() +export type ExtraUsage = z.infer + export const UsageSnapshotSchema = z.discriminatedUnion('provider', [ UsageSnapshotFieldsSchema.extend({ + extraUsage: ExtraUsageSchema.nullish().default(null), + measuredSpendUsd: z.number().nonnegative().nullish().default(null), provider: z.literal('openai'), resetCredits: ResetCreditCountsSchema.nullish().default(null), - source: z.enum(['codexUsageEndpoint', 'proxyResponseHeaders']) + source: z.enum(['codexUsageEndpoint', 'proxyResponseHeaders', 'apiKeyProbe']) }).strict(), UsageSnapshotFieldsSchema.extend({ + extraUsage: ExtraUsageSchema.nullish().default(null), + measuredSpendUsd: z.number().nonnegative().nullish().default(null), provider: z.literal('anthropic'), - source: z.enum(['claudeUsageEndpoint', 'proxyResponseHeaders']) + source: z.enum(['claudeUsageEndpoint', 'proxyResponseHeaders', 'apiKeyProbe']) }).strict() ]) export type UsageSnapshot = z.infer diff --git a/src/manager.ts b/src/manager.ts index 67c291d..0b57572 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -373,6 +373,16 @@ export class AccountManager { account.provider === 'anthropic' ? await probeClaude({ account, ...shared }) : await probeCodex({ account, ...shared }) + if (account.auth === 'apiKey') { + const start = this.#dependencies.now().getTime() - 31 * 24 * 3_600_000 + result.usage.measuredSpendUsd = this.#store + .accountTokens(account.id, start) + .reduce( + (total, row) => + total + costUsd(row.model, row.input, row.output, row.cached, row.cacheCreation), + 0 + ) + } this.#store.saveUsage(result.usage) this.#store.saveAccount(result.account) } diff --git a/src/selection.test.ts b/src/selection.test.ts index ee13ec7..232d1cf 100644 --- a/src/selection.test.ts +++ b/src/selection.test.ts @@ -6,6 +6,7 @@ const NOW = new Date('2026-07-16T17:30:00.000Z') function account(n: number, health: Account['health'] = 'ready'): Account { return { + auth: 'oauth', createdAt: '2026-06-01T00:00:00.000Z', enabled: true, externalAccountId: `acct_${n}`, @@ -14,6 +15,7 @@ function account(n: number, health: Account['health'] = 'ready'): Account { id: `00000000-0000-4000-8000-${n.toString().padStart(12, '0')}`, identity: `user${n}@example.com`, label: `user${n}@example.com`, + onThreshold: 'switch', plan: 'max', profilePath: `/tmp/profiles/${n}`, provider: 'anthropic', @@ -29,7 +31,9 @@ function usage( ): UsageSnapshot { return { accountId: account(n).id, + extraUsage: null, hardLimitReached: options.hardLimitReached ?? false, + measuredSpendUsd: null, observedAt: new Date(NOW.getTime() - (options.ageMs ?? 10_000)).toISOString(), provider: 'anthropic', source: 'proxyResponseHeaders', @@ -141,3 +145,36 @@ describe('selectRotation', () => { expect(decision).toEqual({ reason: 'activeUsageStale', rotate: false }) }) }) + +describe('extra usage spill', () => { + const extra = (exhausted: boolean) => ({ + balanceUsd: 20, + enabled: true, + exhausted, + limitUsd: null, + spentUsd: null, + usedPercent: null + }) + + test('an account set to spill holds through the threshold while credits remain', () => { + const spiller = { ...account(1), onThreshold: 'spill' as const } + const decision = selectRotation({ + accounts: [spiller, account(2)], + now: NOW, + state: state(1), + usage: [{ ...usage(1, 96), extraUsage: extra(false) }, usage(2, 10)] + }) + expect(decision).toEqual({ reason: 'spillingIntoExtraUsage', rotate: false }) + }) + + test('exhausted credits end the spill and rotation resumes', () => { + const spiller = { ...account(1), onThreshold: 'spill' as const } + const decision = selectRotation({ + accounts: [spiller, account(2)], + now: NOW, + state: state(1), + usage: [{ ...usage(1, 96), extraUsage: extra(true) }, usage(2, 10)] + }) + expect(decision.rotate).toBe(true) + }) +}) diff --git a/src/selection.ts b/src/selection.ts index e545ef4..780247a 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -23,6 +23,7 @@ const RotationDecisionSchema = z.discriminatedUnion('rotate', [ 'activeUsageUnknown', 'activeUsageStale', 'belowThreshold', + 'spillingIntoExtraUsage', 'minimumDwell', 'noEligibleCandidate' ]), @@ -83,6 +84,14 @@ export function selectRotation(input: RotationInput): RotationDecision { if (activeSnapshot === undefined) { return { reason: 'activeUsageUnknown', rotate: false } } + const activeAccount = input.accounts.find(account => account.id === input.state.activeAccountId) + if ( + activeAccount?.onThreshold === 'spill' && + activeSnapshot.extraUsage?.enabled === true && + !activeSnapshot.extraUsage.exhausted + ) { + return { reason: 'spillingIntoExtraUsage', rotate: false } + } if (!isFresh(activeSnapshot, input.now, policy.maximumSnapshotAgeMilliseconds)) { return { reason: 'activeUsageStale', rotate: false } } diff --git a/src/storage.ts b/src/storage.ts index 4d6877a..774deff 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -62,6 +62,10 @@ export interface StateStore { timeframes: readonly { key: string; ms: number }[] ): TokenTimeframeAggregate[] tokensBetween(startMillis: number, endMillis: number): number + accountTokens( + accountId: string, + startMillis: number + ): { model: string; input: number; output: number; cached: number; cacheCreation: number }[] } interface JsonRow { @@ -565,6 +569,24 @@ export function createStateStore(databasePath: string): StateStore { }) } + function accountTokens(accountId: string, startMillis: number) { + return database + .query< + { + model: string | null + input: number + output: number + cached: number + cacheCreation: number + }, + [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' + ) + .all(accountId, startMillis) + .map(row => ({ ...row, model: row.model ?? 'unknown' })) + } + function tokensBetween(startMillis: number, endMillis: number): number { const row = database .query<{ tokens: number | null }, [number, number]>( @@ -575,6 +597,7 @@ export function createStateStore(databasePath: string): StateStore { } return { + accountTokens, close: () => database.close(), commitSwitch, dashboard, diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 6a7dcbe..a1f938b 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -14,6 +14,7 @@ import type { import { readAnalytics, refreshUsage, + requestAccountSave, requestConsumeReset, requestPolicy, requestResetCredits, @@ -134,6 +135,35 @@ function panelResetColumn(snapshot: DashboardSnapshot, provider: ProviderId): nu ) } +function spendCell( + account: Account, + usage: UsageSnapshot | undefined +): { label: string; value: string } | null { + if (account.auth !== 'apiKey') { + return null + } + return { label: ' spend ', value: `${moneyUsd(usage?.measuredSpendUsd ?? 0)} · 31d` } +} + +function extraCell( + account: Account, + usage: UsageSnapshot | undefined +): { label: string; value: string; usedPercent: number | null } | null { + const extra = usage?.extraUsage + if (account.auth === 'apiKey' || extra?.enabled !== true) { + return null + } + const value = + extra.usedPercent !== null + ? percentLabel(extra.usedPercent) + : extra.balanceUsd !== null + ? moneyUsd(extra.balanceUsd) + : extra.spentUsd !== null + ? moneyUsd(extra.spentUsd) + : 'on' + return { label: ' extra ', usedPercent: extra.usedPercent, value } +} + function panelBadgeColumn(ctx: Ctx, snapshot: DashboardSnapshot, provider: ProviderId): number { return snapshot.accounts.some( account => account.provider === provider && healthBadge(ctx.theme, account) !== null @@ -158,9 +188,15 @@ function accountsWidth(ctx: Ctx, snapshot: DashboardSnapshot): number { panelResetColumn(snapshot, account.provider) + panelBadgeColumn(ctx, snapshot, account.provider) + 1 - const body = visible - .slice(0, windowsShown(ctx)) - .reduce((sum, window) => sum + windowCellWidth(ctx.tier, window), 0) + const spend = spendCell(account, usage) + const extra = extraCell(account, usage) + const body = + (spend === null + ? visible + .slice(0, windowsShown(ctx)) + .reduce((sum, window) => sum + windowCellWidth(ctx.tier, window), 0) + : spend.label.length + spend.value.length) + + (extra === null ? 0 : extra.label.length + extra.value.length) widest = Math.max(widest, base + body) } return Math.min(CONTENT_MAX, ctx.columns - 2, widest + 2) @@ -295,13 +331,32 @@ function accountLine( }) ] const visible = visibleWindows(usage?.windows ?? [], hiddenIds) - if (visible.length === 0) { + const spend = spendCell(account, usage) + if (spend !== null) { + children.push( + Text({ content: spend.label, fg: rgb(ctx.theme.dim) }), + Text({ content: spend.value, fg: rgb(ctx.theme.fg) }) + ) + } else if (visible.length === 0) { children.push(Text({ content: ' …', fg: rgb(ctx.theme.dim) })) } else { for (const window of visible.slice(0, windowsShown(ctx))) { children.push(...windowCell(ctx, window, BAR[ctx.tier], true)) } } + const extra = extraCell(account, usage) + if (extra !== null) { + children.push( + Text({ + content: extra.label, + fg: rgb(account.onThreshold === 'spill' ? ctx.theme.accent : ctx.theme.dim) + }), + Text({ + content: extra.value, + fg: rgb(extra.usedPercent === null ? ctx.theme.fg : pressureColor(ctx.theme, extra.usedPercent)) + }) + ) + } return Box( { backgroundColor: isSelected ? rgb(ctx.theme.selected) : rgb(ctx.theme.bg), @@ -936,17 +991,21 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt const refreshed = freshestMillis === 0 ? '—' : `${relativeAge(freshestMillis, ctx.now)} ago` const timeframe = TIMEFRAMES[state.timeframeIndex] ?? fallbackTimeframe const selectedRow = rows[state.selected] + const selectedUsage = + selectedRow === undefined + ? undefined + : analytics.snapshot.usage.find(u => u.accountId === selectedRow.accountId) const resettable = state.tab === 'accounts' && selectedRow !== undefined && selectedRow.accountId !== ADD_ROW && - (accountResetCredits(analytics.snapshot.usage.find(u => u.accountId === selectedRow.accountId)) - ?.available ?? 0) > 0 + (accountResetCredits(selectedUsage)?.available ?? 0) > 0 + const spillable = state.tab === 'accounts' && selectedUsage?.extraUsage?.enabled === true const footer = state.resetConfirm !== null ? '⏎ use one reset · esc keep it banked' : state.tab === 'accounts' - ? `↑↓ select · ⏎ switch/add · a auto${resettable ? ' · r reset' : ''} · tab next` + ? `↑↓ select · ⏎ switch/add · a auto${resettable ? ' · r reset' : ''}${spillable ? ' · e spill' : ''} · tab next` : state.tab === 'analytics' ? '←→ range · m chart/metrics · ↑↓ scroll · tab next' : '↑↓ select · ←→ adjust · ⏎ toggle · tab next' @@ -1440,6 +1499,29 @@ export async function runTuiDashboard( if (row !== undefined && row.accountId !== ADD_ROW) { toggleAuto(row.provider) } + } else if (key.name === 'e' && live) { + const row = rows[state.selected] + const account = + row === undefined ? undefined : analytics.snapshot.accounts.find(a => a.id === row.accountId) + const usage = + row === undefined + ? undefined + : analytics.snapshot.usage.find(u => u.accountId === row.accountId) + if (account !== undefined && usage?.extraUsage?.enabled === true) { + const onThreshold = account.onThreshold === 'spill' ? 'switch' : 'spill' + void withBusy( + onThreshold === 'spill' ? 'spilling into extra usage…' : 'switching at threshold…', + async () => { + await requestAccountSave( + socketPath, + { ...account, onThreshold, updatedAt: new Date().toISOString() }, + { profilePath: null, secretReference: null } + ) + analytics = await readAnalytics(socketPath) + rows = orderedRows(analytics.snapshot) + } + ) + } } } catch {} }) diff --git a/src/tui/fixtures.ts b/src/tui/fixtures.ts index c64ebc3..c48e7de 100644 --- a/src/tui/fixtures.ts +++ b/src/tui/fixtures.ts @@ -3,6 +3,7 @@ import { type AnalyticsSnapshot, AnalyticsSnapshotSchema, type AutomationPolicy, + type ExtraUsage, type ProviderId, type ProviderState, TIMEFRAMES, @@ -155,10 +156,14 @@ interface AccountSeed { health?: Account['health'] windows?: WindowSpec[] resetCredits?: { available: number; applicable: number } + extraUsage?: ExtraUsage + auth?: 'oauth' | 'apiKey' + measuredSpendUsd?: number } function account(seed: AccountSeed, now: number): Account { const base = { + auth: seed.auth ?? 'oauth', createdAt: new Date(now - 34 * DAY).toISOString(), enabled: true, externalAccountId: @@ -169,6 +174,7 @@ function account(seed: AccountSeed, now: number): Account { id: uuid(seed.n), identity: seed.email, label: seed.email, + onThreshold: 'switch', plan: seed.plan, updatedAt: new Date(now - 2 * MINUTE).toISOString() } as const @@ -193,7 +199,9 @@ function usage(seed: AccountSeed, now: number): UsageSnapshot { const windows = (seed.windows ?? []).map(spec => toWindow(spec, now)) const base = { accountId: uuid(seed.n), + extraUsage: seed.extraUsage ?? null, hardLimitReached: windows.some(window => window.usedPercent >= 100), + measuredSpendUsd: seed.measuredSpendUsd ?? null, observedAt: new Date( now - 8_000 - Math.round(Math.abs(noise(Math.floor(now / (5 * MINUTE)))) * 16_000) ).toISOString(), @@ -311,6 +319,14 @@ const cruising: ScenarioBuilder = now => [ { email: 'dexter@rubriclabs.com', + extraUsage: { + balanceUsd: 18.5, + enabled: true, + exhausted: false, + limitUsd: null, + spentUsd: null, + usedPercent: null + }, n: 1, plan: 'pro', provider: 'openai', @@ -326,6 +342,14 @@ const cruising: ScenarioBuilder = now => }, { email: 'dexter@rubriclabs.com', + extraUsage: { + balanceUsd: null, + enabled: true, + exhausted: false, + limitUsd: 50, + spentUsd: 12.4, + usedPercent: 25 + }, n: 3, plan: 'claude_max_20x', provider: 'anthropic', @@ -337,6 +361,14 @@ const cruising: ScenarioBuilder = now => plan: 'claude_max_5x', provider: 'anthropic', windows: [claudeSession(18, 0.24, 32), weekly(22, 0.4, 42), fable(12, 0.2, 52)] + }, + { + auth: 'apiKey', + email: 'anthropic api · prod', + measuredSpendUsd: 23.71, + n: 5, + plan: null, + provider: 'anthropic' } ], [ From 6c05bd0045e0c280781480d3cc0940c96a6c9785 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 16:21:10 -0400 Subject: [PATCH 2/9] add-account chooser: sign in or api key --- src/cli.ts | 6 +- src/tui/dashboard.ts | 137 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 127 insertions(+), 16 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2a15bf8..8344380 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -819,8 +819,10 @@ export async function runCli(rawArguments: readonly string[]): Promise { if (action === undefined) { break } - if (action.kind === 'relogin' || action.kind === 'login') { - await login(context, action.provider === 'openai' ? 'codex' : 'claude').catch(error => { + if (action.kind === 'relogin' || action.kind === 'login' || action.kind === 'loginApiKey') { + await login(context, action.provider === 'openai' ? 'codex' : 'claude', { + apiKey: action.kind === 'loginApiKey' + }).catch(error => { alert = errorMessage(error) }) continue diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index a1f938b..134970c 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -142,7 +142,7 @@ function spendCell( if (account.auth !== 'apiKey') { return null } - return { label: ' spend ', value: `${moneyUsd(usage?.measuredSpendUsd ?? 0)} · 31d` } + return { label: ' 31d spend ', value: moneyUsd(usage?.measuredSpendUsd ?? 0) } } function extraCell( @@ -889,6 +889,69 @@ interface ResetConfirm { credits: ResetCreditsView } +interface AddConfirm { + provider: ProviderId + choice: 'oauth' | 'apiKey' +} + +function addConfirmBody(ctx: Ctx, confirm: AddConfirm) { + const cli = providerCli[confirm.provider] + const installed = ctx.cliPresent[confirm.provider] + const line = (...children: ReturnType[]) => + Box( + { flexDirection: 'row', width: '100%' }, + Text({ content: ' ', fg: rgb(ctx.theme.bg) }), + ...children + ) + const option = (selected: boolean, title: string, note: string | null, details: string[]) => [ + line( + Text({ + content: selected ? '▸ ' : '○ ', + fg: rgb(selected ? ctx.theme.accent : ctx.theme.faint) + }), + Text({ + attributes: selected ? 1 : 0, + content: title, + fg: rgb(selected ? ctx.theme.fg : ctx.theme.dim) + }), + Text({ content: note === null ? '' : ` · ${note}`, fg: rgb(ctx.theme.warn) }) + ), + ...details.map(detail => line(Text({ content: ` ${detail}`, fg: rgb(ctx.theme.dim) }))) + ] + const card = Box( + { + border: true, + borderColor: rgb(ctx.theme.accent), + borderStyle: 'rounded', + flexDirection: 'column', + title: ` Add a ${providerShort[confirm.provider]} account `, + titleColor: rgb(ctx.theme.accent), + width: '100%' + }, + blankRow(ctx), + ...option( + confirm.choice === 'oauth', + `sign in with ${cli}`, + installed ? null : `install ${cli} first`, + ['your subscription account; its rate-limit windows meter here'] + ), + blankRow(ctx), + ...option(confirm.choice === 'apiKey', 'add an api key', null, [ + 'bills per token at api rates; account limits do not apply', + 'finishes in the terminal: paste the key, name the account' + ]), + blankRow(ctx), + line( + Text({ attributes: 1, bg: rgb(ctx.theme.selected), content: ' ⏎ ', fg: rgb(ctx.theme.fg) }), + Text({ content: ' continue ', fg: rgb(ctx.theme.dim) }), + Text({ attributes: 1, bg: rgb(ctx.theme.selected), content: ' esc ', fg: rgb(ctx.theme.fg) }), + Text({ content: ' back', fg: rgb(ctx.theme.dim) }) + ), + blankRow(ctx) + ) + return column(ctx, [card], 70) +} + function resetNote(outcome: ResetOutcome): string { switch (outcome.code) { case 'reset': @@ -971,6 +1034,7 @@ interface ViewState { modelScroll: number note: string alert: string + addConfirm: AddConfirm | null resetConfirm: ResetConfirm | null updateAvailable: string | null updateDismissed: boolean @@ -979,6 +1043,7 @@ interface ViewState { type DashboardAction = | { kind: 'relogin'; provider: ProviderId } | { kind: 'login'; provider: ProviderId } + | { kind: 'loginApiKey'; provider: ProviderId } | { kind: 'routing'; provider: ProviderId; enable: boolean } | { kind: 'update'; version: string } @@ -1002,13 +1067,15 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt (accountResetCredits(selectedUsage)?.available ?? 0) > 0 const spillable = state.tab === 'accounts' && selectedUsage?.extraUsage?.enabled === true const footer = - state.resetConfirm !== null - ? '⏎ use one reset · esc keep it banked' - : state.tab === 'accounts' - ? `↑↓ select · ⏎ switch/add · a auto${resettable ? ' · r reset' : ''}${spillable ? ' · e spill' : ''} · tab next` - : state.tab === 'analytics' - ? '←→ range · m chart/metrics · ↑↓ scroll · tab next' - : '↑↓ select · ←→ adjust · ⏎ toggle · tab next' + state.addConfirm !== null + ? '↑↓ choose · ⏎ continue · esc back' + : state.resetConfirm !== null + ? '⏎ use one reset · esc keep it banked' + : state.tab === 'accounts' + ? `↑↓ select · ⏎ switch/add · a auto${resettable ? ' · r reset' : ''}${spillable ? ' · e spill' : ''} · tab next` + : state.tab === 'analytics' + ? '←→ range · m chart/metrics · ↑↓ scroll · tab next' + : '↑↓ select · ←→ adjust · ⏎ toggle · tab next' const header = Box( { flexDirection: 'row', justifyContent: 'center', width: '100%' }, Box( @@ -1059,9 +1126,11 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt } children.push( state.tab === 'accounts' - ? state.resetConfirm !== null - ? resetConfirmBody(ctx, analytics.snapshot, state.resetConfirm) - : accountsBody(ctx, analytics.snapshot, rows, state.selected) + ? state.addConfirm !== null + ? addConfirmBody(ctx, state.addConfirm) + : state.resetConfirm !== null + ? resetConfirmBody(ctx, analytics.snapshot, state.resetConfirm) + : accountsBody(ctx, analytics.snapshot, rows, state.selected) : state.tab === 'analytics' ? analyticsBody(ctx, analytics, timeframe, state) : settingsBody( @@ -1120,6 +1189,7 @@ export async function runTuiDashboard( : buildScenario(fixture.name, simulatedNow) let rows = orderedRows(analytics.snapshot) const state: ViewState = { + addConfirm: null, alert: options.alert ?? '', analyticsView: 'chart', modelScroll: 0, @@ -1219,7 +1289,11 @@ export async function runTuiDashboard( return } if (row.accountId === ADD_ROW) { - finish({ kind: 'login', provider: row.provider }) + state.addConfirm = { + choice: cliPresent[row.provider] ? 'oauth' : 'apiKey', + provider: row.provider + } + paint() return } if (needsLogin(row.accountId)) { @@ -1417,6 +1491,32 @@ export async function runTuiDashboard( state.alert = '' paint() } + if (state.addConfirm !== null) { + const confirm = state.addConfirm + if (key.ctrl && key.name === 'c') { + finish() + } else if (key.name === 'up' || key.name === 'down' || key.name === 'k' || key.name === 'j') { + state.addConfirm = { + ...confirm, + choice: confirm.choice === 'oauth' ? 'apiKey' : 'oauth' + } + paint() + } else if (key.name === 'return') { + state.addConfirm = null + if (live) { + finish({ + kind: confirm.choice === 'apiKey' ? 'loginApiKey' : 'login', + provider: confirm.provider + }) + } else { + paint() + } + } else if (key.name === 'escape' || key.name === 'q') { + state.addConfirm = null + paint() + } + return + } if (state.resetConfirm !== null) { if (key.ctrl && key.name === 'c') { finish() @@ -1492,8 +1592,17 @@ export async function runTuiDashboard( } else if (key.name === 'down' || key.name === 'j') { state.selected = Math.max(0, Math.min(rows.length - 1, state.selected + 1)) paint() - } else if (key.name === 'return' && live) { - switchToSelected() + } else if (key.name === 'return') { + const row = rows[state.selected] + if (row !== undefined && row.accountId === ADD_ROW) { + state.addConfirm = { + choice: cliPresent[row.provider] ? 'oauth' : 'apiKey', + provider: row.provider + } + paint() + } else if (live) { + switchToSelected() + } } else if (key.name === 'a' && live) { const row = rows[state.selected] if (row !== undefined && row.accountId !== ADD_ROW) { From aec00cd11ceb44166496704863190c78284411c1 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 16:54:36 -0400 Subject: [PATCH 3/9] the spend cell fits the row grid, 1m label --- src/tui/dashboard.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 134970c..fa92b51 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -136,13 +136,20 @@ function panelResetColumn(snapshot: DashboardSnapshot, provider: ProviderId): nu } function spendCell( + tier: Tier, account: Account, usage: UsageSnapshot | undefined -): { label: string; value: string } | null { +): { label: string; bar: string; value: string; pad: string } | null { if (account.auth !== 'apiKey') { return null } - return { label: ' 31d spend ', value: moneyUsd(usage?.measuredSpendUsd ?? 0) } + const money = moneyUsd(usage?.measuredSpendUsd ?? 0) + return { + bar: '┄'.repeat(BAR[tier]), + label: ' 1m ', + pad: ''.padEnd(6), + value: ` ${money.padStart(4)}` + } } function extraCell( @@ -188,14 +195,14 @@ function accountsWidth(ctx: Ctx, snapshot: DashboardSnapshot): number { panelResetColumn(snapshot, account.provider) + panelBadgeColumn(ctx, snapshot, account.provider) + 1 - const spend = spendCell(account, usage) + const spend = spendCell(ctx.tier, account, usage) const extra = extraCell(account, usage) const body = (spend === null ? visible .slice(0, windowsShown(ctx)) .reduce((sum, window) => sum + windowCellWidth(ctx.tier, window), 0) - : spend.label.length + spend.value.length) + + : spend.label.length + spend.bar.length + spend.value.length + spend.pad.length) + (extra === null ? 0 : extra.label.length + extra.value.length) widest = Math.max(widest, base + body) } @@ -331,11 +338,13 @@ function accountLine( }) ] const visible = visibleWindows(usage?.windows ?? [], hiddenIds) - const spend = spendCell(account, usage) + const spend = spendCell(ctx.tier, account, usage) if (spend !== null) { children.push( Text({ content: spend.label, fg: rgb(ctx.theme.dim) }), - Text({ content: spend.value, fg: rgb(ctx.theme.fg) }) + Text({ content: spend.bar, fg: rgb(ctx.theme.faint) }), + Text({ content: spend.value, fg: rgb(ctx.theme.fg) }), + Text({ content: spend.pad, fg: rgb(ctx.theme.faint) }) ) } else if (visible.length === 0) { children.push(Text({ content: ' …', fg: rgb(ctx.theme.dim) })) From 4501bedb3204f39b3852c9f8c289e7f51f31c6c8 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 16:54:36 -0400 Subject: [PATCH 4/9] never refuse to start over data --- src/cli.ts | 9 ++++++++- src/storage.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++ src/storage.ts | 30 ++++++++++++++++----------- 3 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 src/storage.test.ts diff --git a/src/cli.ts b/src/cli.ts index 8344380..c3bd7b3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -365,9 +365,16 @@ async function startDaemon(context: ApplicationContext): Promise { } await Bun.sleep(500) } + const logPath = join(context.paths.runtime, 'daemon.log') + const lastError = await readFile(logPath, 'utf8') + .then(log => log.trim().split('\n').at(-1) ?? '') + .catch(() => '') throw new ApplicationError( 'DAEMON_START_FAILED', - `Manager did not start; inspect ${join(context.paths.runtime, 'daemon.log')}` + `Manager did not start${lastError === '' ? '' : ` — ${lastError.replace(/^tokenmaxx: /, '')}`}\n` + + 'Your clients still route through tokenmaxx while it is down.\n' + + 'Escape hatch: tokenmaxx uninstall (codex and claude talk straight to the providers again)\n' + + `Then check tokenmaxx doctor, or the full log: ${logPath}` ) } finally { closeSync(logDescriptor) diff --git a/src/storage.test.ts b/src/storage.test.ts new file mode 100644 index 0000000..3e7ca50 --- /dev/null +++ b/src/storage.test.ts @@ -0,0 +1,49 @@ +import { Database } from 'bun:sqlite' +import { describe, expect, test } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createStateStore } from './storage.ts' + +describe('a database with rows this build cannot read', () => { + test('still opens, lists what parses, and never kills the daemon', () => { + const path = join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite') + const seed = createStateStore(path) + seed.saveAccount({ + auth: 'oauth', + createdAt: '2026-07-01T00:00:00.000Z', + enabled: true, + externalAccountId: 'acct-good', + externalUserId: null, + health: 'ready', + id: '00000000-0000-4000-8000-000000000301', + identity: 'good@rubriclabs.com', + label: 'good@rubriclabs.com', + onThreshold: 'switch', + plan: 'max', + profilePath: '/tmp/p', + provider: 'anthropic', + secretReference: null, + updatedAt: '2026-07-01T00:00:00.000Z' + }) + seed.close() + + const database = new Database(path) + database + .query( + "INSERT INTO accounts(id, provider, label, payload) VALUES ('bad-row', 'anthropic', 'future@rubriclabs.com', ?)" + ) + .run('{"provider":"anthropic","fromTheFuture":true}') + database + .query("UPDATE provider_states SET payload = 'not json at all' WHERE provider = 'openai'") + .run() + database.close() + + const store = createStateStore(path) + expect(store.listAccounts().map(account => account.label)).toEqual(['good@rubriclabs.com']) + expect(store.findAccount('bad-row')).toBeNull() + expect(store.findProviderState('openai').policy.thresholdPercent).toBe(90) + expect(store.dashboard().accounts).toHaveLength(1) + store.close() + }) +}) diff --git a/src/storage.ts b/src/storage.ts index 774deff..5a53124 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -84,13 +84,13 @@ function parsePayload(row: JsonRow | null, schema: PersistedSchema): if (row === null) { return null } - try { return schema.parse(JSON.parse(row.payload)) - } catch (error) { - throw new ApplicationError('CORRUPT_STATE', 'Stored state failed schema validation', { - cause: error - }) + } catch { + process.stderr.write( + `[${new Date().toISOString()}] skipping a stored row this build cannot read (newer or older schema); it stays on disk untouched\n` + ) + return null } } @@ -130,7 +130,10 @@ function migratePolicyDefaults(database: Database): void { 'SELECT provider, payload FROM provider_states' ) for (const row of rows.all()) { - const state = parseRequiredPayload(row, ProviderStateSchema) + const state = parsePayload(row, ProviderStateSchema) + if (state === null) { + continue + } const policy = { ...state.policy } if (policy.maximumSnapshotAgeMilliseconds === 120_000) { policy.maximumSnapshotAgeMilliseconds = 420_000 @@ -244,7 +247,10 @@ function migrate(database: Database): void { 'UPDATE accounts SET label = ?, external_account_id = ?, external_user_id = ? WHERE id = ?' ) for (const row of migrationRows) { - const account = parseRequiredPayload(row, AccountSchema) + const account = parsePayload(row, AccountSchema) + if (account === null) { + continue + } updateMigratedAccount.run( account.label, account.externalAccountId, @@ -305,7 +311,10 @@ export function createStateStore(databasePath: string): StateStore { return database .query(sql) .all(...params) - .map(row => parseRequiredPayload(row, schema)) + .flatMap(row => { + const parsed = parsePayload(row, schema) + return parsed === null ? [] : [parsed] + }) } function listAccounts(provider?: ProviderId): Account[] { @@ -426,10 +435,7 @@ export function createStateStore(databasePath: string): StateStore { .query('SELECT payload FROM provider_states WHERE provider = ?') .get(parsedProvider) const state = parsePayload(row, ProviderStateSchema) - if (state === null) { - throw new ApplicationError('STATE_NOT_FOUND', `Missing ${provider} provider state`) - } - return state + return state ?? initialProviderState(parsedProvider) } function saveProviderState(state: ProviderState): void { From ba39df95f6376a9949ef2b4008ff7a7139fff3b1 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 17:00:34 -0400 Subject: [PATCH 5/9] 31d floats left into the label column --- src/tui/dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index fa92b51..2d0c67c 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -146,7 +146,7 @@ function spendCell( const money = moneyUsd(usage?.measuredSpendUsd ?? 0) return { bar: '┄'.repeat(BAR[tier]), - label: ' 1m ', + label: '31d ', pad: ''.padEnd(6), value: ` ${money.padStart(4)}` } From 46b05206ce6a1dfffe90593b4c1fa20bd547ff88 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 17:03:07 -0400 Subject: [PATCH 6/9] hand the terminal back cleanly before login flows --- src/cli.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c3bd7b3..4b98a73 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -464,6 +464,29 @@ function assertCliInstalled(provider: ProviderId): void { } } +async function handTerminalBack(): Promise { + if (process.stdin.isTTY !== true) { + return + } + try { + Bun.spawnSync(['stty', 'sane'], { stderr: 'ignore', stdin: 'inherit', stdout: 'ignore' }) + } catch {} + process.stdin.resume() + await Bun.sleep(50) + try { + while (process.stdin.read() !== null) {} + } catch {} + process.stdin.pause() +} + +async function ask(question: string): Promise { + process.stdout.write(question) + for await (const line of console) { + return line.trim() + } + return '' +} + async function registerApiKeyAccount( provider: 'openai' | 'anthropic', keyArgument: string | undefined @@ -474,8 +497,8 @@ async function registerApiKeyAccount( 'Pass the key inline in non-interactive shells: tokenmaxx login --api-key ' ) } - const key = keyArgument ?? prompt('Paste the API key:') ?? '' - const label = prompt('Name this account (shown in the dashboard):') ?? '' + const key = keyArgument ?? (await ask('Paste the API key: ')) + const label = await ask('Name this account (shown in the dashboard): ') if (label.trim().length === 0) { throw new ApplicationError('USAGE', 'The account needs a name') } From d0ab4556fc7afcb9499769de785e34db91e88340 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 17:14:55 -0400 Subject: [PATCH 7/9] login flows open on a clean screen --- src/cli.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 4b98a73..b8d5611 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -479,6 +479,14 @@ async function handTerminalBack(): Promise { process.stdin.pause() } +function freshScreen(title: string): void { + const color = + process.stdout.isTTY === true && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb' + const accent = (text: string) => (color ? `\x1b[38;2;90;176;255m${text}\x1b[0m` : text) + const dim = (text: string) => (color ? `\x1b[38;2;139;147;161m${text}\x1b[0m` : text) + process.stdout.write(`\x1b[2J\x1b[H${accent('tokenmaxx')} ${dim(`· ${title}`)}\n\n`) +} + async function ask(question: string): Promise { process.stdout.write(question) for await (const line of console) { From 8909b8acc63040da9803498f8b178de701428ff6 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 17:23:01 -0400 Subject: [PATCH 8/9] quiet the terminal handoff for real --- src/cli.test.ts | 11 +++++++++++ src/cli.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- src/storage.ts | 8 -------- src/tui/dashboard.ts | 3 +++ 4 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 src/cli.test.ts diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..85341a9 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'bun:test' +import { stripTerminalNoise } from './cli.ts' + +describe('stripTerminalNoise', () => { + test('terminal chatter never survives into a typed answer', () => { + expect(stripTerminalNoise('\x1b[B\x1b[Ask-ant-abc123')).toBe('sk-ant-abc123') + expect(stripTerminalNoise('\x1bP1+r4d73=1b5d\x1b\\sk-proj-xyz')).toBe('sk-proj-xyz') + expect(stripTerminalNoise('\x1b]0;4:00 on ttys005\x07my key')).toBe('my key') + expect(stripTerminalNoise(' plain-key-42 ')).toBe('plain-key-42') + }) +}) diff --git a/src/cli.ts b/src/cli.ts index b8d5611..b6fdb2c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -487,10 +487,47 @@ function freshScreen(title: string): void { process.stdout.write(`\x1b[2J\x1b[H${accent('tokenmaxx')} ${dim(`· ${title}`)}\n\n`) } +function skipEscapeSequence(line: string, start: number): number { + const kind = line[start + 1] + if (kind === '[') { + let index = start + 2 + while (index < line.length && !/[a-zA-Z~]/.test(line[index] ?? '')) { + index += 1 + } + return index + 1 + } + if (kind === ']' || kind === 'P') { + let index = start + 2 + while (index < line.length && line.charCodeAt(index) !== 7 && line.charCodeAt(index) !== 27) { + index += 1 + } + return line.charCodeAt(index) === 27 ? index + 2 : index + 1 + } + return start + 2 +} + +export function stripTerminalNoise(line: string): string { + let clean = '' + let index = 0 + while (index < line.length) { + const code = line.charCodeAt(index) + if (code === 27) { + index = skipEscapeSequence(line, index) + continue + } + if (code >= 32 && code !== 127) { + clean += line[index] + } + index += 1 + } + return clean.trim() +} + async function ask(question: string): Promise { + await handTerminalBack() process.stdout.write(question) for await (const line of console) { - return line.trim() + return stripTerminalNoise(line) } return '' } @@ -854,11 +891,14 @@ export async function runCli(rawArguments: readonly string[]): Promise { routing: await readRouting() }) alert = '' + await handTerminalBack() if (action === undefined) { break } if (action.kind === 'relogin' || action.kind === 'login' || action.kind === 'loginApiKey') { - await login(context, action.provider === 'openai' ? 'codex' : 'claude', { + const cli = action.provider === 'openai' ? 'codex' : 'claude' + freshScreen(action.kind === 'loginApiKey' ? `add a ${cli} api key` : `sign in with ${cli}`) + await login(context, cli, { apiKey: action.kind === 'loginApiKey' }).catch(error => { alert = errorMessage(error) diff --git a/src/storage.ts b/src/storage.ts index 5a53124..c98daad 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -94,14 +94,6 @@ function parsePayload(row: JsonRow | null, schema: PersistedSchema): } } -function parseRequiredPayload(row: JsonRow, schema: PersistedSchema): Type { - const parsed = parsePayload(row, schema) - if (parsed === null) { - throw new ApplicationError('CORRUPT_STATE', 'Stored row unexpectedly has no payload') - } - return parsed -} - function serialize(value: unknown): string { return JSON.stringify(value) } diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 2d0c67c..9255e15 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -1184,6 +1184,9 @@ export async function runTuiDashboard( ): Promise { const fixture = options.fixture const live = fixture === undefined + try { + process.stdin.setRawMode?.(true) + } catch {} const cliPresent: Record = live ? { anthropic: Bun.which('claude') !== null, openai: Bun.which('codex') !== null } : { anthropic: true, openai: true } From 823a1e937b4a6e9a591a0d2b3ff16cc37b349f75 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 17:29:58 -0400 Subject: [PATCH 9/9] read the key prompts through readline, one interface --- src/cli.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b6fdb2c..cadc67c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { closeSync, openSync } from 'node:fs' import { type FileHandle, mkdir, open, readFile, rm, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { createInterface } from 'node:readline/promises' import { z } from 'zod' import { registerClaudeAccount, registerClaudeApiKeyAccount } from './claude.ts' import { registerCodexAccount, registerOpenAiApiKeyAccount } from './codex.ts' @@ -523,15 +524,6 @@ export function stripTerminalNoise(line: string): string { return clean.trim() } -async function ask(question: string): Promise { - await handTerminalBack() - process.stdout.write(question) - for await (const line of console) { - return stripTerminalNoise(line) - } - return '' -} - async function registerApiKeyAccount( provider: 'openai' | 'anthropic', keyArgument: string | undefined @@ -542,8 +534,18 @@ async function registerApiKeyAccount( 'Pass the key inline in non-interactive shells: tokenmaxx login --api-key ' ) } - const key = keyArgument ?? (await ask('Paste the API key: ')) - const label = await ask('Name this account (shown in the dashboard): ') + await handTerminalBack() + const readline = createInterface({ input: process.stdin, output: process.stdout }) + let key: string + let label: string + try { + key = keyArgument ?? stripTerminalNoise(await readline.question('Paste the API key: ')) + label = stripTerminalNoise( + await readline.question('Name this account (shown in the dashboard): ') + ) + } finally { + readline.close() + } if (label.trim().length === 0) { throw new ApplicationError('USAGE', 'The account needs a name') }