diff --git a/CHANGELOG.md b/CHANGELOG.md index 246de57..3c17bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-09-25] pick which accounts auto-rotation uses first - [2026-08-21] codex keeps its shell and apply_patch tools on the gpt-5.6 models - [2026-08-18] meter clients that hang up early - [2026-08-18] reclaim bare provider tables diff --git a/README.md b/README.md index 4354428..b15f728 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,12 @@ Turn it on and tokenmaxx watches the active account's rate-limit windows. When t tokenmaxx auto both on --threshold 90 # or: codex | claude … off ``` +Want some accounts used before others? Put them in order. Auto-rotation then switches to the first account in your order that still has room, instead of the emptiest one, and moves back to an earlier account once its window resets, after the cooldown. Accounts you leave out go after the ones you list. In the dashboard, `[` and `]` move the selected account. + +```bash +tokenmaxx order claude work@acme.com personal@me.com # --reset goes back to "most room" +``` + ## How it works A single loopback proxy on `127.0.0.1:8459`, and the clients you already use. @@ -79,6 +85,7 @@ tokenmaxx install route native codex & claude tokenmaxx uninstall restore native config tokenmaxx switch make an account active tokenmaxx logout [codex|claude] sign out; the credential is deleted +tokenmaxx order [email…] which accounts auto-rotation uses first tokenmaxx auto [--threshold N] tokenmaxx list | status | refresh | doctor ``` diff --git a/package.json b/package.json index 7ec8ed3..ac9da2d 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.66" + "version": "0.0.67" } diff --git a/src/account-order.test.ts b/src/account-order.test.ts new file mode 100644 index 0000000..17557ac --- /dev/null +++ b/src/account-order.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Account } from './domain.ts' +import { AccountManager } from './manager.ts' +import { applicationPaths } from './paths.ts' +import { createStateStore } from './storage.ts' + +function account(n: number): Account { + return { + auth: 'oauth', + createdAt: '2026-06-01T00:00:00.000Z', + enabled: true, + externalAccountId: `acct_${n}`, + externalUserId: null, + health: 'ready', + 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: null, + provider: 'anthropic', + secretReference: `claude:${n}`, + updatedAt: '2026-06-01T00:00:00.000Z' + } +} + +function setup() { + const home = mkdtempSync(join(tmpdir(), 'tokenmaxx-order-')) + const paths = applicationPaths({ TOKENMAXX_HOME: home }) + const store = createStateStore(join(home, 'state.sqlite')) + for (const n of [1, 2, 3]) { + store.saveAccount(account(n)) + } + const vault = { + read: async () => null, + remove: async () => undefined, + write: async () => undefined + } + return { manager: new AccountManager({ paths, store, vault }), store } +} + +const priorities = (accounts: readonly Account[]) => + Object.fromEntries(accounts.map(candidate => [candidate.label, candidate.priority])) + +describe('account order', () => { + test('puts the listed accounts first and keeps the rest after them', async () => { + const { manager, store } = setup() + await manager.setAccountOrder('anthropic', [account(3).id]) + expect(priorities(store.listAccounts('anthropic'))).toEqual({ + 'user1@example.com': 1, + 'user2@example.com': 2, + 'user3@example.com': 0 + }) + await manager.setAccountOrder('anthropic', [account(2).id, account(3).id]) + expect(priorities(store.listAccounts('anthropic'))).toEqual({ + 'user1@example.com': 2, + 'user2@example.com': 0, + 'user3@example.com': 1 + }) + }) + + test('an empty list clears the order', async () => { + const { manager, store } = setup() + await manager.setAccountOrder('anthropic', [account(2).id]) + await manager.setAccountOrder('anthropic', []) + expect(store.listAccounts('anthropic').every(candidate => candidate.priority === undefined)).toBe( + true + ) + }) + + test('rejects an account from another provider', async () => { + const { manager } = setup() + await expect(manager.setAccountOrder('openai', [account(1).id])).rejects.toThrow( + 'No openai account' + ) + }) +}) diff --git a/src/cli.ts b/src/cli.ts index 356b520..c428135 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,7 @@ import { managerVersion, readDashboard, readProxyPort, + requestAccountOrder, requestAccountRemove, requestAccountSave, requestSwitch, @@ -34,6 +35,7 @@ import { import { AccountManager } from './manager.ts' import { type ApplicationPaths, applicationPaths, ensureApplicationPaths } from './paths.ts' import { proxyIdentity } from './proxy.ts' +import { orderRank } from './selection.ts' import { createStateStore, type StateStore } from './storage.ts' import { renderDashboard } from './ui.ts' import { createMacOsKeychainVault } from './vault.ts' @@ -251,6 +253,11 @@ function help(): string { row('list', 'accounts, health, and live usage'), row('switch ', 'make an account active now'), row('logout [codex|claude] ', 'sign out and delete the credential'), + row( + 'order [email…]', + 'which accounts auto-rotate uses first', + 'no emails prints the order · --reset clears it' + ), row( 'auto ', 'switch accounts at a usage threshold', @@ -272,6 +279,8 @@ function help(): string { dim(' with the most headroom, and an interrupted request is retried there.'), dim(' Threshold switches hold for 5 minutes to avoid flapping; hard limits'), dim(' ignore the hold. Turning auto on is what authorizes the switching.'), + dim(' With an order set, it switches to the first account in the order with'), + dim(' room, and moves back to an earlier one once it has room again.'), '', dim('Once installed, use codex and claude normally — a local proxy injects the'), dim("active account's credential per request, so a switch takes effect on the"), @@ -680,21 +689,76 @@ function listAccounts(context: ApplicationContext): void { ['openai', 'codex'], ['anthropic', 'claude'] ] as const) { - const group = accounts.filter(account => account.provider === provider) + const group = inOrder(accounts.filter(account => account.provider === provider)) if (group.length === 0) { continue } process.stdout.write(`\n${title}\n`) for (const account of group) { const isActive = states.get(provider)?.activeAccountId === account.id + const place = account.priority === undefined ? ' ' : `${account.priority + 1}.` process.stdout.write( - ` ${isActive ? '●' : ' '} ${account.label.padEnd(width)} ${healthText[account.health]}\n` + ` ${isActive ? '●' : ' '} ${place} ${account.label.padEnd(width)} ${healthText[account.health]}\n` ) } } process.stdout.write('\n● = active\n') } +function inOrder(accounts: readonly Account[]): Account[] { + return [...accounts].sort( + (left, right) => orderRank(left) - orderRank(right) || left.label.localeCompare(right.label) + ) +} + +async function orderAccounts( + context: ApplicationContext, + arguments_: readonly string[] +): Promise { + const providerArgument = arguments_[0] + if (providerArgument === undefined) { + throw new ApplicationError( + 'USAGE', + 'Usage: tokenmaxx order [email…] | tokenmaxx order --reset' + ) + } + const provider = providerFromCli(providerArgument) + const references = arguments_.slice(1).filter(argument => argument !== '--reset') + const reset = arguments_.includes('--reset') + if (!reset && references.length === 0) { + const group = inOrder(context.store.listAccounts(provider)) + if (group.every(account => account.priority === undefined)) { + process.stdout.write( + `No order set for ${providerArgument}; auto-rotate picks the account with the most room.\n` + ) + return + } + for (const account of group) { + process.stdout.write(` ${(account.priority ?? group.length) + 1}. ${account.label}\n`) + } + return + } + const accountIds = reset + ? [] + : references.map(reference => resolveAccount(context.store, provider, reference).id) + await ensureDaemon(context) + const snapshot = await requestAccountOrder(context.paths.managerSocket, provider, accountIds) + if (reset) { + process.stdout.write( + `Cleared the ${providerArgument} order; auto-rotate picks the account with the most room.\n` + ) + return + } + const ordered = inOrder(snapshot.accounts.filter(account => account.provider === provider)) + process.stdout.write(`Auto-rotate for ${providerArgument} now uses, in order:\n`) + for (const account of ordered) { + process.stdout.write(` ${(account.priority ?? 0) + 1}. ${account.label}\n`) + } + process.stdout.write( + 'When an earlier account has room again, tokenmaxx moves back to it after the cooldown.\n' + ) +} + const providerWords = new Set(['codex', 'claude', 'openai', 'anthropic']) async function logout(context: ApplicationContext, arguments_: readonly string[]): Promise { @@ -1041,6 +1105,9 @@ export async function runCli(rawArguments: readonly string[]): Promise { case 'logout': await logout(context, arguments_.slice(1)) return 0 + case 'order': + await orderAccounts(context, arguments_.slice(1)) + return 0 case 'auto': await configureAutomation(context, arguments_.slice(1)) return 0 diff --git a/src/domain.ts b/src/domain.ts index 0677a20..9db6687 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -35,6 +35,7 @@ const AccountFieldsSchema = z.object({ label: AccountNameSchema, onThreshold: z.enum(['switch', 'spill']).default('switch'), plan: z.string().trim().min(1).nullish(), + priority: z.number().int().nonnegative().optional(), updatedAt: z.iso.datetime() }) diff --git a/src/ipc.ts b/src/ipc.ts index 1d84d1b..8a52ad0 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -62,6 +62,10 @@ const PolicyParamsSchema = z const ResetParamsSchema = z.object({ accountId: z.uuid() }).strict() +const OrderParamsSchema = z + .object({ accountIds: z.array(z.uuid()), provider: ProviderIdSchema }) + .strict() + const ReplaceCredentialParamsSchema = z .object({ account: AccountSchema, @@ -115,6 +119,11 @@ async function dispatch( await manager.removeAccount(ResetParamsSchema.parse(params).accountId) return { removed: true } } + case 'account/order': { + const parsed = OrderParamsSchema.parse(params) + await manager.setAccountOrder(parsed.provider, parsed.accountIds) + return manager.dashboard() + } case 'codex/resetCredits': return manager.codexResetCredits(ResetParamsSchema.parse(params).accountId) case 'codex/consumeReset': @@ -395,3 +404,17 @@ export function requestAccountSave( timeoutMilliseconds: 15_000 }).then(() => undefined) } + +export function requestAccountOrder( + socketPath: string, + provider: ProviderId, + accountIds: readonly string[] +): Promise { + return managerRequest({ + method: 'account/order', + params: { accountIds, provider }, + schema: DashboardSnapshotSchema, + socketPath, + timeoutMilliseconds: 15_000 + }) +} diff --git a/src/manager.ts b/src/manager.ts index 3079ce5..850afef 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -28,7 +28,7 @@ import { startProxy, type UpstreamInjection } from './proxy.ts' -import { selectRotation } from './selection.ts' +import { orderRank, selectRotation } from './selection.ts' import type { StateStore, TokenTimeframeAggregate } from './storage.ts' import type { CredentialVault } from './vault.ts' @@ -318,6 +318,39 @@ export class AccountManager { }) } + /** Puts `accountIds` first in this order and keeps the rest after them; an empty list clears the order. */ + public async setAccountOrder(provider: ProviderId, accountIds: readonly string[]): Promise { + return this.withProviderOperation(provider, async () => { + const accounts = this.#store.listAccounts(provider) + const listed = [...new Set(accountIds)].map(id => { + const account = accounts.find(candidate => candidate.id === id) + if (account === undefined) { + throw new ApplicationError('ACCOUNT_NOT_FOUND', `No ${provider} account ${id}`) + } + return account + }) + const rest = accounts + .filter(account => !listed.includes(account)) + .sort( + (left, right) => orderRank(left) - orderRank(right) || left.label.localeCompare(right.label) + ) + const now = this.#dependencies.now().toISOString() + const ordered = listed.length === 0 ? accounts : [...listed, ...rest] + ordered.forEach((account, index) => { + const priority = listed.length === 0 ? undefined : index + if (account.priority !== priority) { + const { priority: _, ...unordered } = account + this.#store.saveAccount( + priority === undefined + ? { ...unordered, updatedAt: now } + : { ...account, priority, updatedAt: now } + ) + } + }) + await this.evaluateAutomation(provider) + }) + } + public setAutomationPolicy(input: { provider: ProviderId enabled?: boolean diff --git a/src/selection.test.ts b/src/selection.test.ts index 232d1cf..39bc563 100644 --- a/src/selection.test.ts +++ b/src/selection.test.ts @@ -178,3 +178,81 @@ describe('extra usage spill', () => { expect(decision.rotate).toBe(true) }) }) + +describe('account order', () => { + const ranked = (n: number, priority: number) => ({ ...account(n), priority }) + + test('rotates onto the first account in the order that has room, not the emptiest', () => { + const decision = selectRotation({ + accounts: [ranked(1, 0), ranked(2, 1), ranked(3, 2)], + now: NOW, + state: state(1), + usage: [usage(1, 92), usage(2, 60), usage(3, 5)] + }) + expect(decision).toMatchObject({ reason: 'threshold', targetAccountId: account(2).id }) + }) + + test('skips an ordered account without room', () => { + const decision = selectRotation({ + accounts: [ranked(1, 0), ranked(2, 1), ranked(3, 2)], + now: NOW, + state: state(1), + usage: [usage(1, 92), usage(2, 88), usage(3, 50)] + }) + expect(decision).toMatchObject({ targetAccountId: account(3).id }) + }) + + test('ordered accounts come before unordered ones', () => { + const decision = selectRotation({ + accounts: [account(1), account(2), ranked(3, 0)], + now: NOW, + state: state(1), + usage: [usage(1, 95), usage(2, 5), usage(3, 70)] + }) + expect(decision).toMatchObject({ targetAccountId: account(3).id }) + }) + + test('moves back to an earlier account once it has room again', () => { + const decision = selectRotation({ + accounts: [ranked(1, 0), ranked(2, 1)], + now: NOW, + state: state(2, { switchedAgoMs: 600_000 }), + usage: [usage(1, 20), usage(2, 40)] + }) + expect(decision).toMatchObject({ + reason: 'preferred', + rotate: true, + targetAccountId: account(1).id + }) + }) + + test('does not move back to an earlier account still above the ceiling', () => { + const decision = selectRotation({ + accounts: [ranked(1, 0), ranked(2, 1)], + now: NOW, + state: state(2, { switchedAgoMs: 600_000 }), + usage: [usage(1, 87), usage(2, 40)] + }) + expect(decision).toEqual({ reason: 'belowThreshold', rotate: false }) + }) + + test('moving back waits out the cooldown', () => { + const decision = selectRotation({ + accounts: [ranked(1, 0), ranked(2, 1)], + now: NOW, + state: state(2, { switchedAgoMs: 60_000 }), + usage: [usage(1, 20), usage(2, 40)] + }) + expect(decision).toEqual({ reason: 'minimumDwell', rotate: false }) + }) + + test('never moves to a later account below the threshold', () => { + const decision = selectRotation({ + accounts: [ranked(1, 0), ranked(2, 1)], + now: NOW, + state: state(1, { switchedAgoMs: 600_000 }), + usage: [usage(1, 70), usage(2, 0)] + }) + expect(decision).toEqual({ reason: 'belowThreshold', rotate: false }) + }) +}) diff --git a/src/selection.ts b/src/selection.ts index 780247a..318fd4d 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -8,7 +8,7 @@ import { const RotationDecisionSchema = z.discriminatedUnion('rotate', [ z.object({ - reason: z.enum(['threshold', 'hardLimit']), + reason: z.enum(['threshold', 'hardLimit', 'preferred']), rotate: z.literal(true), sourceAccountId: z.uuid(), sourcePressure: z.number().min(0).max(100), @@ -52,6 +52,11 @@ function isFresh(snapshot: UsageSnapshot, now: Date, maximumAgeMilliseconds: num return Number.isFinite(observedAt) && age >= -5_000 && age <= maximumAgeMilliseconds } +// Accounts without a place in the order come after every ordered one. +export function orderRank(account: Account | undefined): number { + return account?.priority ?? Number.POSITIVE_INFINITY +} + function eligibleHealth(account: Account): boolean { switch (account.health) { case 'ready': @@ -99,15 +104,7 @@ export function selectRotation(input: RotationInput): RotationDecision { if (activePressure === null) { return { reason: 'activeUsageUnknown', rotate: false } } - if (!activeSnapshot.hardLimitReached && activePressure < policy.thresholdPercent) { - return { reason: 'belowThreshold', rotate: false } - } - if (!activeSnapshot.hardLimitReached && input.state.switchedAt !== null) { - const dwell = input.now.getTime() - Date.parse(input.state.switchedAt) - if (dwell < policy.minimumDwellMilliseconds) { - return { reason: 'minimumDwell', rotate: false } - } - } + const overThreshold = activeSnapshot.hardLimitReached || activePressure >= policy.thresholdPercent const targetCeiling = policy.thresholdPercent - policy.hysteresisPercent const candidates = input.accounts @@ -135,16 +132,30 @@ export function selectRotation(input: RotationInput): RotationDecision { }) .sort( (left, right) => - left.pressure - right.pressure || left.account.id.localeCompare(right.account.id) + orderRank(left.account) - orderRank(right.account) || + left.pressure - right.pressure || + left.account.id.localeCompare(right.account.id) ) - const target = candidates[0] + // Below the threshold, the only reason to move is an account earlier in the order with room again. + const target = overThreshold + ? candidates[0] + : candidates.find(candidate => orderRank(candidate.account) < orderRank(activeAccount)) + if (!overThreshold && target === undefined) { + return { reason: 'belowThreshold', rotate: false } + } + if (!activeSnapshot.hardLimitReached && input.state.switchedAt !== null) { + const dwell = input.now.getTime() - Date.parse(input.state.switchedAt) + if (dwell < policy.minimumDwellMilliseconds) { + return { reason: 'minimumDwell', rotate: false } + } + } if (target === undefined) { return { reason: 'noEligibleCandidate', rotate: false } } return { - reason: activeSnapshot.hardLimitReached ? 'hardLimit' : 'threshold', + reason: activeSnapshot.hardLimitReached ? 'hardLimit' : overThreshold ? 'threshold' : 'preferred', rotate: true, sourceAccountId: input.state.activeAccountId, sourcePressure: activePressure, diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 61209ad..b8c1042 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -15,6 +15,7 @@ import type { import { readAnalytics, refreshUsage, + requestAccountOrder, requestAccountSave, requestConsumeReset, requestPolicy, @@ -23,6 +24,7 @@ import { } from '../ipc.ts' import { applicationPaths } from '../paths.ts' import { readPreferences, writePreferences } from '../preferences.ts' +import { orderRank } from '../selection.ts' import { availableUpdate, installedVersion, VERSION } from '../version.ts' import { buildScenario } from './fixtures.ts' import { @@ -252,6 +254,10 @@ function orderedRows(snapshot: DashboardSnapshot): Row[] { const accounts = snapshot.accounts .filter(account => account.provider === provider) .sort((left, right) => { + const byOrder = orderRank(left) - orderRank(right) + if (byOrder !== 0 && !Number.isNaN(byOrder)) { + return byOrder + } const byPressure = pressure(right.id) - pressure(left.id) return byPressure !== 0 ? byPressure : left.label.localeCompare(right.label) }) @@ -414,7 +420,12 @@ function providerPanel( ) }) const routed = ctx.routing[provider] - const auto = state?.policy.enabled ? `auto ${state.policy.thresholdPercent}%` : 'auto off' + const ordered = snapshot.accounts.some( + account => account.provider === provider && account.priority !== undefined + ) + const auto = state?.policy.enabled + ? `auto ${state.policy.thresholdPercent}%${ordered ? ' · in order' : ''}` + : 'auto off' const title = routed ? ` ${providerTitles[provider]} ● ${auto} ` : ` ${providerTitles[provider]} ✗ off ` @@ -1200,7 +1211,7 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt : 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` + ? `↑↓ select · ⏎ switch/add · [ ] order · 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' @@ -1564,6 +1575,30 @@ export async function runTuiDashboard( ) } + // Rows already list each provider's accounts in auto-rotate order, so moving a row moves it in that order. + const moveSelected = (delta: -1 | 1) => { + const row = rows[state.selected] + if (row === undefined || row.accountId === ADD_ROW) { + return + } + const order = rows + .filter(candidate => candidate.provider === row.provider && candidate.accountId !== ADD_ROW) + .map(candidate => candidate.accountId) + const from = order.indexOf(row.accountId) + const to = from + delta + if (to < 0 || to >= order.length) { + return + } + order.splice(from, 1) + order.splice(to, 0, row.accountId) + void withBusy('reordering…', async () => { + await requestAccountOrder(socketPath, row.provider, order) + analytics = await readAnalytics(socketPath) + rows = orderedRows(analytics.snapshot) + state.selected = rows.findIndex(candidate => candidate.accountId === row.accountId) + }) + } + const toggleWindow = (provider: ProviderId, windowId: string) => { const hidden = currentPolicy(provider)?.hiddenWindowIds ?? [] const next = hidden.includes(windowId) @@ -1822,6 +1857,8 @@ export async function runTuiDashboard( if (row !== undefined && row.accountId !== ADD_ROW) { toggleAuto(row.provider) } + } else if ((key.name === '[' || key.name === ']') && live) { + moveSelected(key.name === '[' ? -1 : 1) } else if (key.name === 'e' && live) { const row = rows[state.selected] const account =