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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -79,6 +85,7 @@ tokenmaxx install route native codex & claude
tokenmaxx uninstall restore native config
tokenmaxx switch <codex|claude> <email> make an account active
tokenmaxx logout [codex|claude] <email> sign out; the credential is deleted
tokenmaxx order <codex|claude> [email…] which accounts auto-rotation uses first
tokenmaxx auto <both|codex|claude> <on|off> [--threshold N]
tokenmaxx list | status | refresh | doctor
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.66"
"version": "0.0.67"
}
80 changes: 80 additions & 0 deletions src/account-order.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
71 changes: 69 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
managerVersion,
readDashboard,
readProxyPort,
requestAccountOrder,
requestAccountRemove,
requestAccountSave,
requestSwitch,
Expand All @@ -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'
Expand Down Expand Up @@ -251,6 +253,11 @@ function help(): string {
row('list', 'accounts, health, and live usage'),
row('switch <codex|claude> <email>', 'make an account active now'),
row('logout [codex|claude] <email>', 'sign out and delete the credential'),
row(
'order <codex|claude> [email…]',
'which accounts auto-rotate uses first',
'no emails prints the order · --reset clears it'
),
row(
'auto <codex|claude|both> <on|off>',
'switch accounts at a usage threshold',
Expand All @@ -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"),
Expand Down Expand Up @@ -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<void> {
const providerArgument = arguments_[0]
if (providerArgument === undefined) {
throw new ApplicationError(
'USAGE',
'Usage: tokenmaxx order <codex|claude> [email…] | tokenmaxx order <codex|claude> --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<void> {
Expand Down Expand Up @@ -1041,6 +1105,9 @@ export async function runCli(rawArguments: readonly string[]): Promise<number> {
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
Expand Down
1 change: 1 addition & 0 deletions src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
23 changes: 23 additions & 0 deletions src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -395,3 +404,17 @@ export function requestAccountSave(
timeoutMilliseconds: 15_000
}).then(() => undefined)
}

export function requestAccountOrder(
socketPath: string,
provider: ProviderId,
accountIds: readonly string[]
): Promise<DashboardSnapshot> {
return managerRequest({
method: 'account/order',
params: { accountIds, provider },
schema: DashboardSnapshotSchema,
socketPath,
timeoutMilliseconds: 15_000
})
}
35 changes: 34 additions & 1 deletion src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<void> {
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
Expand Down
Loading
Loading