diff --git a/src/cliproxy/accounts/__tests__/drain-order.test.ts b/src/cliproxy/accounts/__tests__/drain-order.test.ts index 02a0e013..42ad8242 100644 --- a/src/cliproxy/accounts/__tests__/drain-order.test.ts +++ b/src/cliproxy/accounts/__tests__/drain-order.test.ts @@ -591,3 +591,133 @@ describe('attribution stability - auth_index unaffected by priority rewrite', () expect(map.get('1')).toBe('string@x.com'); }); }); + +// --------------------------------------------------------------------------- +// resolveEffectiveDrainOrder - shared effective-order resolver +// (consumed by `accounts order` show AND the quota pool section) +// --------------------------------------------------------------------------- + +describe('resolveEffectiveDrainOrder', () => { + it('returns empty file-order result for no active accounts', async () => { + await withIsolatedHome(async () => { + const { resolveEffectiveDrainOrder } = await loadDrainOrder(); + const result = resolveEffectiveDrainOrder('claude', []); + expect(result.mode).toBe('file'); + expect(result.entries).toEqual([]); + expect(result.hasDrift).toBe(false); + }); + }); + + it('uses stable file order (tieBreakKey) and reports no drift when no config stored', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'claude-b.json', { email: 'b@x.com' }); + writeAuthFile(authDir, 'claude-a.json', { email: 'a@x.com' }); + + const { resolveEffectiveDrainOrder } = await loadDrainOrder(); + const result = resolveEffectiveDrainOrder('claude', [ + { accountId: 'b@x.com', tokenFile: 'claude-b.json' }, + { accountId: 'a@x.com', tokenFile: 'claude-a.json' }, + ]); + expect(result.mode).toBe('file'); + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'a@x.com', + 'b@x.com', + ]); + expect(result.hasDrift).toBe(false); + }); + }); + + it('file-order: residual on-disk priorities reorder display to match the selector and flag drift', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + // No drain order config stored -> file mode. But residual priorities are + // left on disk (e.g. after `order --reset` did not strip the attribute). + // The selector follows the file, so b (priority 5) must sort before a + // (priority 1) even though tieBreakKey alone would put a first, and drift + // is flagged so the user knows the file disagrees with "plain file order". + writeAuthFile(authDir, 'claude-a.json', { email: 'a@x.com', priority: 1 }); + writeAuthFile(authDir, 'claude-b.json', { email: 'b@x.com', priority: 5 }); + + const { resolveEffectiveDrainOrder } = await loadDrainOrder(); + const result = resolveEffectiveDrainOrder('claude', [ + { accountId: 'a@x.com', tokenFile: 'claude-a.json' }, + { accountId: 'b@x.com', tokenFile: 'claude-b.json' }, + ]); + expect(result.mode).toBe('file'); + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'b@x.com', + 'a@x.com', + ]); + expect(result.hasDrift).toBe(true); + }); + }); + + it('sorts manual order by ON-DISK priority, not computed config, and flags drift', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + // Two registered accounts; manual config says a then b (a > b computed). + writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' }); + writeAuthFile(authDir, 'antigravity-b.json', { email: 'b@x.com', type: 'antigravity' }); + + const { registerAccount, saveDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + // On disk, re-auth/--reset left b with a HIGHER priority than a. The + // selector follows the file, so b should come first and drift is flagged. + const { writeAuthFilePriorityDirect, resolveEffectiveDrainOrder } = await loadDrainOrder(); + writeAuthFilePriorityDirect('antigravity-a.json', 1); + writeAuthFilePriorityDirect('antigravity-b.json', 5); + + const result = resolveEffectiveDrainOrder('agy', [ + { accountId: 'a@x.com', tokenFile: 'antigravity-a.json' }, + { accountId: 'b@x.com', tokenFile: 'antigravity-b.json' }, + ]); + expect(result.mode).toBe('manual'); + // b@x.com first because its ON-DISK priority (5) beats a@x.com (1). + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'b@x.com', + 'a@x.com', + ]); + expect(result.hasDrift).toBe(true); + }); + }); + + it('reports no drift when on-disk priorities match the computed manual config', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' }); + writeAuthFile(authDir, 'antigravity-b.json', { email: 'b@x.com', type: 'antigravity' }); + + const { registerAccount, saveDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + // Apply the computed config to disk first, then resolve: no drift. + const { computeManualDrainOrder, applyDrainOrder, resolveEffectiveDrainOrder } = + await loadDrainOrder(); + const entries = computeManualDrainOrder( + ['a@x.com', 'b@x.com'], + [ + { accountId: 'a@x.com', tokenFile: 'antigravity-a.json' }, + { accountId: 'b@x.com', tokenFile: 'antigravity-b.json' }, + ] + ); + await applyDrainOrder(entries, false); + + const result = resolveEffectiveDrainOrder('agy', [ + { accountId: 'a@x.com', tokenFile: 'antigravity-a.json' }, + { accountId: 'b@x.com', tokenFile: 'antigravity-b.json' }, + ]); + expect(result.mode).toBe('manual'); + expect(result.entries.map((e: { accountId: string }) => e.accountId)).toEqual([ + 'a@x.com', + 'b@x.com', + ]); + expect(result.hasDrift).toBe(false); + }); + }); +}); diff --git a/src/cliproxy/accounts/__tests__/pool-state.test.ts b/src/cliproxy/accounts/__tests__/pool-state.test.ts new file mode 100644 index 00000000..afb9301b --- /dev/null +++ b/src/cliproxy/accounts/__tests__/pool-state.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for the pool state resolver: per-account state classification + * (available / cooling / paused), drain order modes, and the cooling-vs-paused + * distinction driven by the persisted quota-cooldown store. + */ +import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runWithScopedCcsHome } from '../../../utils/config-manager'; +import type { AccountInfo } from '../types'; +import type { PoolRoutingSettings } from '../pool-state'; + +const SETTINGS_OFF: PoolRoutingSettings = { + poolEnabled: false, + strategy: 'round-robin', + sessionAffinityEnabled: false, + sessionAffinityTtl: '1h', +}; + +function account(partial: Partial & { id: string }): AccountInfo { + return { + provider: 'claude', + isDefault: false, + tokenFile: `${partial.id}.json`, + createdAt: '2026-01-01T00:00:00.000Z', + ...partial, + } as AccountInfo; +} + +async function withIsolatedHome(fn: (homeDir: string) => Promise | T): Promise { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-state-')); + try { + return await runWithScopedCcsHome(homeDir, () => fn(homeDir)); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +} + +async function loadPoolState() { + return import(`../pool-state?pool-state=${Date.now()}`); +} + +describe('resolvePoolState - account state classification', () => { + it('labels an unpaused, non-cooling account as available', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('available'); + }); + }); + + it('labels a manually paused account (no cooldown record) as paused', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com', paused: true, pausedAt: '2026-06-01T00:00:00.000Z' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('paused'); + expect(pool.states[0].pausedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + }); + + it('labels a paused account with a matching quota-cooldown record as cooling', async () => { + await withIsolatedHome(async (homeDir) => { + const pausedAt = '2026-06-10T12:00:00.000Z'; + const until = 2_000_000; + const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json'); + fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true }); + fs.writeFileSync( + quotaPausedPath, + JSON.stringify({ + entries: [ + { + provider: 'claude', + accountId: 'a@x.com', + pausedAt, + until, + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com', paused: true, pausedAt })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('cooling'); + expect(pool.states[0].cooldownUntil).toBe(until); + expect(pool.states[0].cooldownSource).toBe('persisted'); + }); + }); + + it('treats a manual pause layered over a stale cooldown record as paused (pausedAt mismatch)', async () => { + await withIsolatedHome(async (homeDir) => { + const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json'); + fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true }); + fs.writeFileSync( + quotaPausedPath, + JSON.stringify({ + entries: [ + { + provider: 'claude', + accountId: 'a@x.com', + pausedAt: '2026-06-10T12:00:00.000Z', + until: 2_000_000, + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + // Different pausedAt -> not the same pause the cooldown record describes. + accounts: [account({ id: 'a@x.com', paused: true, pausedAt: '2026-06-11T09:00:00.000Z' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('paused'); + }); + }); + + it('does not treat an expired persisted cooldown as cooling', async () => { + await withIsolatedHome(async (homeDir) => { + const quotaPausedPath = path.join(homeDir, '.ccs', 'cliproxy', 'quota-paused.json'); + fs.mkdirSync(path.dirname(quotaPausedPath), { recursive: true }); + fs.writeFileSync( + quotaPausedPath, + JSON.stringify({ + entries: [ + { + provider: 'claude', + accountId: 'a@x.com', + pausedAt: '2026-06-10T12:00:00.000Z', + until: 500_000, // already past relative to now below + reason: 'quota_exhausted', + }, + ], + }) + ); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [account({ id: 'a@x.com' })], + now: 1_000_000, + }); + expect(pool.states[0].state).toBe('available'); + }); + }); +}); + +describe('resolvePoolState - drain order', () => { + it('uses stable file order when no drain config is stored', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [ + account({ id: 'b@x.com', tokenFile: 'claude-b.json' }), + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + ], + now: 1_000_000, + }); + expect(pool.drainOrder.mode).toBe('file'); + // Token-file byte order: claude-a.json before claude-b.json. + expect(pool.drainOrder.order).toEqual(['a@x.com', 'b@x.com']); + }); + }); + + it('excludes paused accounts from the drain order but keeps them in states', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [ + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + account({ + id: 'b@x.com', + tokenFile: 'claude-b.json', + paused: true, + pausedAt: '2026-06-01T00:00:00.000Z', + }), + ], + now: 1_000_000, + }); + expect(pool.drainOrder.order).toEqual(['a@x.com']); + expect(pool.states.map((s) => s.accountId).sort()).toEqual(['a@x.com', 'b@x.com']); + }); + }); + + it('reports no drift in plain file-order mode', async () => { + await withIsolatedHome(async () => { + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'claude', + settings: SETTINGS_OFF, + accounts: [ + account({ id: 'a@x.com', tokenFile: 'claude-a.json' }), + account({ id: 'b@x.com', tokenFile: 'claude-b.json' }), + ], + now: 1_000_000, + }); + expect(pool.drainOrder.hasDrift).toBe(false); + }); + }); + + it('follows on-disk priority and surfaces drift when the stored order diverges', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + // Auth files on disk with priorities that contradict the stored manual order: + // config says a then b, but disk gives b the higher priority (re-auth/--reset trap). + fs.writeFileSync( + path.join(authDir, 'antigravity-a.json'), + JSON.stringify({ type: 'antigravity', email: 'a@x.com', priority: 1 }) + ); + fs.writeFileSync( + path.join(authDir, 'antigravity-b.json'), + JSON.stringify({ type: 'antigravity', email: 'b@x.com', priority: 5 }) + ); + + const { registerAccount, saveDrainOrderConfig } = await import( + `../registry?pool-state-drift=${Date.now()}` + ); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + saveDrainOrderConfig('agy', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); + + const { resolvePoolState } = await loadPoolState(); + const pool = resolvePoolState({ + provider: 'agy', + settings: SETTINGS_OFF, + accounts: [ + account({ provider: 'agy', id: 'a@x.com', tokenFile: 'antigravity-a.json' }), + account({ provider: 'agy', id: 'b@x.com', tokenFile: 'antigravity-b.json' }), + ], + now: 1_000_000, + }); + // Selector follows the on-disk priority: b (5) before a (1). + expect(pool.drainOrder.order).toEqual(['b@x.com', 'a@x.com']); + expect(pool.drainOrder.hasDrift).toBe(true); + }); + }); +}); diff --git a/src/cliproxy/accounts/account-safety.ts b/src/cliproxy/accounts/account-safety.ts index 3cde38e8..3173bbfe 100644 --- a/src/cliproxy/accounts/account-safety.ts +++ b/src/cliproxy/accounts/account-safety.ts @@ -112,6 +112,37 @@ function loadQuotaPaused(): QuotaPausedFile { return { entries: [] }; } +/** + * Read-only view of a persisted quota-cooldown pause. + * Exposed for visibility surfaces (e.g. `ccs cliproxy quota` pool section) + * that must distinguish a quota cooldown (with a reset time) from a manual pause. + */ +export interface QuotaCooldownEntry { + provider: CLIProxyProvider; + accountId: string; + /** ISO timestamp when the account was paused (matches AccountInfo.pausedAt) */ + pausedAt: string; + /** Epoch ms when the cooldown is eligible to be lifted */ + until: number; + reason: 'quota_exhausted'; +} + +/** + * Return the persisted quota-cooldown pauses recorded on disk. + * + * This is the cross-process source of truth for quota cooldowns: the in-memory + * cooldown map in quota-manager is process-local, but quota-paused.json is + * written by the long-lived proxy/monitor process and read by short-lived CLI + * invocations. Callers use it to label an account as cooling (vs manually + * paused) and to show the reset time. + * + * Entries are returned as-is (including expired ones); callers decide whether to + * treat `until <= now` as already cooled down. + */ +export function readQuotaCooldownEntries(): QuotaCooldownEntry[] { + return loadQuotaPaused().entries.map((entry) => ({ ...entry })); +} + function saveQuotaPaused(data: QuotaPausedFile): void { const filePath = getQuotaPausedPath(); if (data.entries.length === 0) { diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts index 43a63183..d63c350d 100644 --- a/src/cliproxy/accounts/drain-order.ts +++ b/src/cliproxy/accounts/drain-order.ts @@ -19,7 +19,9 @@ import { buildProxyUrl, buildManagementHeaders, } from '../proxy/proxy-target-resolver'; +import { loadDrainOrderConfig } from './registry'; import type { AccountTier } from './types'; +import type { CLIProxyProvider } from '../types'; /** Minimum valid priority value. Management layer treats 0 as delete. */ export const MIN_PRIORITY = 1; @@ -364,3 +366,133 @@ export function annotateCurrentPriorities(entries: DrainOrderEntry[]): void { entry.currentPriority = readAuthFilePriority(entry.tokenFile); } } + +/** + * Tie-break key for a token file, matching the upstream Go selector. + * The selector breaks priority ties by Auth.ID asc. Upstream (synthesizer + * file.go:120-122) lowercases Auth.ID only on Windows; on other platforms it + * compares by raw byte order. We mirror that platform-conditional behaviour so + * the displayed order matches what CLIProxy actually drains. + * + * @param platform process.platform; defaults to the current platform. Exposed + * for tests so non-Windows byte-order behaviour can be asserted deterministically. + */ +export function tieBreakKey( + tokenFile: string, + platform: NodeJS.Platform = process.platform +): string { + return platform === 'win32' ? tokenFile.toLowerCase() : tokenFile; +} + +/** How the effective drain order was derived. */ +export type EffectiveDrainOrderMode = 'manual' | 'tier' | 'file'; + +/** + * Effective drain order as the selector actually sees it: computed from the + * stored config mode, then sorted by the on-disk priority each auth file + * carries (the selector's real input), not by the intended config priority. + */ +export interface EffectiveDrainOrder { + /** How the stored config intended to derive order. */ + mode: EffectiveDrainOrderMode; + /** + * Entries in selector pick order (first = drained first), annotated with the + * on-disk priority via annotateCurrentPriorities(). + */ + entries: DrainOrderEntry[]; + /** + * True when the computed config priority diverges from what is actually on + * disk for any account. Drift happens because re-auth rewrites the auth JSON + * and drops the priority attribute, and --reset leaves residual file + * priorities; under drift the selector follows the file, not the config. + */ + hasDrift: boolean; +} + +/** + * Resolve the effective drain order for a provider's active (non-paused) + * accounts, mirroring what `ccs cliproxy accounts order` shows and what the + * selector actually drains. + * + * Semantics (shared by the order subcommand and the quota pool section): + * 1. Pick the config mode (manual when stored ids still resolve, else tier when + * stored, else file order). + * 2. annotateCurrentPriorities() to read the on-disk priority for each account. + * 3. Sort by currentPriority desc (undefined treated as 0, matching the + * selector), tie-break by tieBreakKey(tokenFile) asc. + * 4. Flag drift when the computed config priority != on-disk priority anywhere. + * + * File mode never writes priorities, but it still honours any residual on-disk + * priority the selector reads (e.g. left by `order --reset`): it sorts by that + * priority desc then tieBreakKey asc, and flags drift when any residual is + * non-zero. With no residuals all priorities are 0 and it falls back to plain + * tieBreakKey order with no drift. + * + * @param activeAccounts Accounts in rotation (callers exclude paused accounts). + */ +export function resolveEffectiveDrainOrder( + provider: CLIProxyProvider, + activeAccounts: DrainOrderInput[] +): EffectiveDrainOrder { + if (activeAccounts.length === 0) { + return { mode: 'file', entries: [], hasDrift: false }; + } + + const drainCfg = loadDrainOrderConfig(provider); + + // Manual mode is only usable when at least one stored ID still maps to a live + // account. If every stored ID is stale, fall back to the file-order view. + let manualValidIds: string[] | undefined; + if (drainCfg?.mode === 'manual' && drainCfg.orderedIds && drainCfg.orderedIds.length > 0) { + const existingIds = new Set(activeAccounts.map((a) => a.accountId)); + manualValidIds = drainCfg.orderedIds.filter((id) => existingIds.has(id)); + } + + let entries: DrainOrderEntry[]; + let mode: EffectiveDrainOrderMode; + + if (manualValidIds && manualValidIds.length > 0) { + entries = computeManualDrainOrder(manualValidIds, activeAccounts); + mode = 'manual'; + } else if (drainCfg?.mode === 'tier') { + entries = computeTierDrainOrder(activeAccounts); + mode = 'tier'; + } else { + // File order: config never writes priorities, so entries carry no computed + // priority. The selector still reads any residual on-disk priority (e.g. + // left behind by `order --reset`), so annotate, sort by that priority like + // the other modes, and flag drift when any residual is non-zero. + entries = activeAccounts.map((a) => ({ + accountId: a.accountId, + tokenFile: a.tokenFile, + priority: MIN_PRIORITY, + tier: a.tier, + tierDerived: false, + currentPriority: undefined, + })); + annotateCurrentPriorities(entries); + const fileHasDrift = entries.some((e) => (e.currentPriority ?? 0) !== 0); + entries.sort( + (a, b) => + (b.currentPriority ?? 0) - (a.currentPriority ?? 0) || + (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1) + ); + return { mode: 'file', entries, hasDrift: fileHasDrift }; + } + + // Annotate on-disk priorities (the selector's real input) before sorting. + annotateCurrentPriorities(entries); + + // Drift: computed config priority diverges from on-disk anywhere. Missing + // file priority (undefined) is treated as 0, matching the selector. + const hasDrift = entries.some((e) => (e.currentPriority ?? 0) !== e.priority); + + // Sort by on-disk priority desc (undefined as 0), tie-break by tokenFile asc. + entries.sort( + (a, b) => + (b.currentPriority ?? 0) - (a.currentPriority ?? 0) || + (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1) + ); + + return { mode, entries, hasDrift }; +} diff --git a/src/cliproxy/accounts/pool-state.ts b/src/cliproxy/accounts/pool-state.ts new file mode 100644 index 00000000..6483880a --- /dev/null +++ b/src/cliproxy/accounts/pool-state.ts @@ -0,0 +1,224 @@ +/** + * Pool State Resolver + * + * Resolves the observable state of an account pool for a provider, combining: + * - effective drain order (Phase 4 resolver: manual / tier / file order) + * - per-account state: available / cooling (quota cooldown, with reset time) / paused + * - routing settings (strategy, session affinity) and pool routing mode + * + * These three account states map to DIFFERENT client-visible failure modes: + * - paused : the account is held out of rotation by the user or a safety guard + * - cooling : the account hit a quota limit and is on a timed cooldown + * - available: the account is eligible for selection + * + * They are named distinctly so visibility surfaces never conflate "I paused it" + * with "it ran out of quota". + * + * Cooldown source precedence: + * - The proxy/monitor process writes quota-paused.json (cross-process truth). + * When pool routing's cooling flip is OFF (stock disable-cooling: true) there + * is simply no cooldown data to show; this resolver reports that honestly. + * - The in-memory quota-manager cooldown is process-local; it only contributes + * when this very process applied a cooldown. The persisted entry wins when + * both exist, because that is the routing truth across processes. + */ + +import { resolveEffectiveDrainOrder, type DrainOrderInput } from './drain-order'; +import { getProviderAccounts } from './query'; +import { readQuotaCooldownEntries } from './account-safety'; +import { getCooldownUntil } from '../quota/quota-manager'; +import type { AccountInfo, AccountTier } from './types'; +import type { CLIProxyProvider } from '../types'; + +/** Providers where tier metadata is tracked and tier-derived order is meaningful. */ +export const POOL_TIER_AWARE_PROVIDERS = new Set(['agy', 'gemini']); + +/** Per-account pool state. */ +export type PoolAccountStateKind = 'available' | 'cooling' | 'paused'; + +/** How the effective drain order was derived. */ +export type PoolDrainOrderMode = 'manual' | 'tier' | 'file'; + +export interface PoolAccountState { + accountId: string; + tokenFile: string; + tier?: AccountTier; + /** Whether this account is the provider default. */ + isDefault: boolean; + /** Resolved state. */ + state: PoolAccountStateKind; + /** + * For state 'cooling': epoch ms when the cooldown is eligible to lift. + * Undefined for other states. + */ + cooldownUntil?: number; + /** + * For state 'cooling': where the cooldown reading came from. + * 'persisted' = quota-paused.json (cross-process), 'memory' = in-process map. + */ + cooldownSource?: 'persisted' | 'memory'; + /** ISO timestamp the account was paused (manual pause), when state is 'paused'. */ + pausedAt?: string; +} + +export interface PoolDrainOrderState { + mode: PoolDrainOrderMode; + /** + * Accounts in effective drain order (first = drained first), as the selector + * actually sees it: sorted by on-disk priority, not the intended config. + * Paused accounts are excluded from the drain order (they are not in rotation) + * but still surface in `states`. + */ + order: string[]; + /** + * True when the stored config order diverges from what is on disk (the + * selector's real input). Surfaced so the quota pool section warns instead of + * silently showing a "next account" that disagrees with the selector. + */ + hasDrift: boolean; +} + +export interface PoolRoutingSettings { + /** Whether pool routing (fill-first + affinity + cooling) is enabled. */ + poolEnabled: boolean; + strategy: string; + sessionAffinityEnabled: boolean; + sessionAffinityTtl: string; + /** Max credentials tried per request before a 429, when pool routing is on. */ + maxRetryCredentials?: number; +} + +export interface PoolState { + provider: CLIProxyProvider; + /** All accounts for the provider with resolved per-account state. */ + states: PoolAccountState[]; + drainOrder: PoolDrainOrderState; + settings: PoolRoutingSettings; +} + +export interface ResolvePoolStateInput { + provider: CLIProxyProvider; + settings: PoolRoutingSettings; + /** Override for tests; defaults to getProviderAccounts(provider). */ + accounts?: AccountInfo[]; + /** Override for tests; defaults to Date.now(). */ + now?: number; +} + +/** + * Classify a single account's pool state. + * + * Precedence: + * 1. A persisted quota cooldown whose pausedAt matches the account's pausedAt is + * a quota cooldown -> 'cooling' (cross-process truth wins). + * 2. A paused account without a matching cooldown record is a manual/safety pause + * -> 'paused'. + * 3. An unpaused account with an in-memory cooldown still in effect -> 'cooling'. + * 4. Otherwise -> 'available'. + */ +function classifyAccount( + provider: CLIProxyProvider, + account: AccountInfo, + cooldownByAccount: Map, + now: number +): PoolAccountState { + const base: PoolAccountState = { + accountId: account.id, + tokenFile: account.tokenFile, + tier: account.tier, + isDefault: account.isDefault, + state: 'available', + }; + + const persisted = cooldownByAccount.get(account.id); + + if (account.paused) { + // A persisted quota cooldown that matches this pause is a cooldown, not a + // manual pause. Match on pausedAt so a manual pause layered over a stale + // cooldown record is not mislabelled. + if (persisted && persisted.pausedAt === account.pausedAt && persisted.until > now) { + return { + ...base, + state: 'cooling', + cooldownUntil: persisted.until, + cooldownSource: 'persisted', + }; + } + return { ...base, state: 'paused', pausedAt: account.pausedAt }; + } + + // Unpaused: a persisted cooldown still in effect is the routing truth. + if (persisted && persisted.until > now) { + return { + ...base, + state: 'cooling', + cooldownUntil: persisted.until, + cooldownSource: 'persisted', + }; + } + + // Fall back to the process-local cooldown map. + const memoryUntil = getCooldownUntil(provider, account.id); + if (memoryUntil !== undefined && memoryUntil > now) { + return { + ...base, + state: 'cooling', + cooldownUntil: memoryUntil, + cooldownSource: 'memory', + }; + } + + return base; +} + +/** + * Compute the effective drain order for the active (non-paused) accounts, + * mirroring the selector's pick order used by `ccs cliproxy accounts order`. + * + * Delegates to the shared resolveEffectiveDrainOrder() so the quota Pool + * section, the `accounts order` show, and the selector all agree: the order is + * sorted by ON-DISK priority (the selector's real input), and any drift between + * the stored config and the auth files is surfaced rather than hidden. + */ +function resolveDrainOrder( + provider: CLIProxyProvider, + activeAccounts: DrainOrderInput[] +): PoolDrainOrderState { + const effective = resolveEffectiveDrainOrder(provider, activeAccounts); + return { + mode: effective.mode, + order: effective.entries.map((e) => e.accountId), + hasDrift: effective.hasDrift, + }; +} + +/** + * Resolve the full observable pool state for a provider. + */ +export function resolvePoolState(input: ResolvePoolStateInput): PoolState { + const { provider, settings } = input; + const now = input.now ?? Date.now(); + const accounts = input.accounts ?? getProviderAccounts(provider); + + // Index persisted quota cooldowns for this provider by account id. + const cooldownByAccount = new Map(); + for (const entry of readQuotaCooldownEntries()) { + if (entry.provider !== provider) continue; + cooldownByAccount.set(entry.accountId, { until: entry.until, pausedAt: entry.pausedAt }); + } + + const states = accounts.map((account) => + classifyAccount(provider, account, cooldownByAccount, now) + ); + + // Drain order is computed over accounts that are in rotation (not paused). + // Cooling accounts remain in the order: cooling is transient and the selector + // still considers them in priority terms once the window lifts. + const activeAccounts: DrainOrderInput[] = accounts + .filter((a) => !a.paused) + .map((a) => ({ accountId: a.id, tokenFile: a.tokenFile, tier: a.tier })); + + const drainOrder = resolveDrainOrder(provider, activeAccounts); + + return { provider, states, drainOrder, settings }; +} diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index fcef7c44..81f38150 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -262,6 +262,26 @@ export function isOnCooldown(provider: CLIProxyProvider, accountId: string): boo return true; } +/** + * Get the epoch-ms timestamp when an account's in-memory cooldown expires. + * Returns undefined when the account is not on cooldown (or the cooldown has + * already lapsed). This is the process-local cooldown; for the cross-process + * source of truth see readQuotaCooldownEntries() in account-safety. + */ +export function getCooldownUntil( + provider: CLIProxyProvider, + accountId: string +): number | undefined { + const key = getCacheKey(provider, accountId); + const entry = cooldownMap.get(key); + if (!entry) return undefined; + if (Date.now() > entry.until) { + cooldownMap.delete(key); + return undefined; + } + return entry.until; +} + /** * Apply cooldown to an exhausted account */ diff --git a/src/cliproxy/routing/routing-strategy.ts b/src/cliproxy/routing/routing-strategy.ts index ee94c6db..b649c773 100644 --- a/src/cliproxy/routing/routing-strategy.ts +++ b/src/cliproxy/routing/routing-strategy.ts @@ -254,12 +254,24 @@ export function disablePoolRouting( const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`; const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`); +/** + * Pool routing mode summary surfaced alongside routing state for the dashboard. + * When pool routing is enabled the generator forces fill-first + session affinity + * + cooling, regardless of stored routing values. + */ +export interface CliproxyPoolRoutingState { + enabled: boolean; + maxRetryCredentials?: number; +} + export interface CliproxyRoutingState { strategy: CliproxyRoutingStrategy; source: 'live' | 'config'; target: 'local' | 'remote'; reachable: boolean; message?: string; + /** Pool routing mode (proxy-wide). Present for both local and remote targets. */ + poolRouting?: CliproxyPoolRoutingState; } export interface CliproxyRoutingApplyResult extends CliproxyRoutingState { @@ -381,8 +393,28 @@ export async function fetchLiveCliproxyRoutingStrategy(): Promise { const target = getCliproxyRoutingTarget(); + const poolRouting = getCliproxyPoolRoutingState(); if (target.isRemote) { return { @@ -390,6 +422,7 @@ export async function readCliproxyRoutingState(): Promise source: 'live', target: 'remote', reachable: true, + poolRouting, }; } @@ -399,6 +432,7 @@ export async function readCliproxyRoutingState(): Promise source: 'live', target: 'local', reachable: true, + poolRouting, }; } catch { return { @@ -407,6 +441,7 @@ export async function readCliproxyRoutingState(): Promise target: 'local', reachable: false, message: 'Local CLIProxy is not reachable. Showing the saved startup default.', + poolRouting, }; } } diff --git a/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts b/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts new file mode 100644 index 00000000..b8c13641 --- /dev/null +++ b/src/commands/cliproxy/__tests__/pool-state-renderer.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for the CLI pool-state renderer helpers: state labels (the three + * account states must be NAMED differently because they map to different client + * failure modes), reset formatting, and drain-order mode labels. + */ +import { describe, expect, it } from 'bun:test'; +import { describeAccountState, formatCooldownReset, modeLabel } from '../pool-state-renderer'; +import type { PoolAccountState } from '../../../cliproxy/accounts/pool-state'; + +function state( + partial: Partial & { state: PoolAccountState['state'] } +): PoolAccountState { + return { + accountId: 'a@x.com', + tokenFile: 'a.json', + isDefault: false, + ...partial, + } as PoolAccountState; +} + +describe('describeAccountState', () => { + it('names available, cooling, and paused states distinctly', () => { + const now = 1_000_000; + const available = describeAccountState(state({ state: 'available' }), now); + const paused = describeAccountState(state({ state: 'paused' }), now); + const cooling = describeAccountState( + state({ state: 'cooling', cooldownUntil: now + 5 * 60 * 1000, cooldownSource: 'persisted' }), + now + ); + + expect(available.label).toBe('available'); + expect(available.tone).toBe('available'); + expect(paused.label).toContain('paused'); + expect(paused.tone).toBe('paused'); + expect(cooling.label).toContain('cooling until'); + expect(cooling.tone).toBe('cooling'); + + // The three labels must be mutually distinct (no conflation). + const labels = new Set([available.label, paused.label, cooling.label]); + expect(labels.size).toBe(3); + }); + + it('annotates an in-process (memory) cooldown source', () => { + const now = 1_000_000; + const cooling = describeAccountState( + state({ state: 'cooling', cooldownUntil: now + 60_000, cooldownSource: 'memory' }), + now + ); + expect(cooling.label).toContain('[in-process]'); + }); + + it('shows unknown when a cooling account has no reset time', () => { + const cooling = describeAccountState(state({ state: 'cooling' }), 1_000_000); + expect(cooling.label).toContain('unknown'); + }); +}); + +describe('formatCooldownReset', () => { + it('returns now for a non-positive delta', () => { + expect(formatCooldownReset(1_000_000, 1_000_000)).toContain('now'); + }); + + it('uses minute granularity within the hour', () => { + const now = 1_000_000; + expect(formatCooldownReset(now + 5 * 60 * 1000, now)).toContain('5m'); + }); + + it('uses hour granularity within the day', () => { + const now = 1_000_000; + expect(formatCooldownReset(now + 3 * 3600 * 1000, now)).toContain('3h'); + }); +}); + +describe('modeLabel', () => { + it('maps each drain order mode to a stable label', () => { + expect(modeLabel('manual')).toBe('manual (--set)'); + expect(modeLabel('tier')).toBe('tier-derived (--by-tier)'); + expect(modeLabel('file')).toBe('file order'); + }); +}); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 4fcb7e6f..27e61e14 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -56,7 +56,10 @@ export async function showHelp(): Promise { ['default ', 'Set default account for rotation'], ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], + [ + 'quota', + 'Show quota status + pool context (drain order, per-account available/cooling/paused)', + ], ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ['routing', 'Show current routing strategy and manual guidance'], ['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'], diff --git a/src/commands/cliproxy/order-subcommand.ts b/src/commands/cliproxy/order-subcommand.ts index 446db691..7ea1de5d 100644 --- a/src/commands/cliproxy/order-subcommand.ts +++ b/src/commands/cliproxy/order-subcommand.ts @@ -10,18 +10,15 @@ import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../../utils/ui'; import { extractOption, hasAnyFlag } from '../arg-extractor'; -import { - saveDrainOrderConfig, - loadDrainOrderConfig, - clearDrainOrderConfig, -} from '../../cliproxy/accounts/registry'; +import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry'; import { getProviderAccounts } from '../../cliproxy/accounts/query'; import { computeManualDrainOrder, computeTierDrainOrder, applyDrainOrder, - annotateCurrentPriorities, + resolveEffectiveDrainOrder, readAuthFilePriority, + tieBreakKey, type DrainOrderEntry, type DrainOrderInput, } from '../../cliproxy/accounts/drain-order'; @@ -35,22 +32,9 @@ import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; /** Providers where tier metadata is expected and --by-tier is meaningful. */ const TIER_AWARE_PROVIDERS = new Set(['agy', 'gemini']); -/** - * Tie-break key for a token file, matching the upstream Go selector. - * The selector breaks priority ties by Auth.ID asc. Upstream (synthesizer - * file.go:120-122) lowercases Auth.ID only on Windows; on other platforms it - * compares by raw byte order. We mirror that platform-conditional behaviour so - * the displayed order matches what CLIProxy actually drains. - * - * @param platform process.platform; defaults to the current platform. Exposed - * for tests so non-Windows byte-order behaviour can be asserted deterministically. - */ -export function tieBreakKey( - tokenFile: string, - platform: NodeJS.Platform = process.platform -): string { - return platform === 'win32' ? tokenFile.toLowerCase() : tokenFile; -} +// tieBreakKey now lives in drain-order.ts (shared with the quota pool section); +// re-exported here so existing callers/tests keep importing it from this module. +export { tieBreakKey }; function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): string { if (!tier || tier === 'unknown') { @@ -133,38 +117,23 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise { return; } - const drainCfg = loadDrainOrderConfig(provider); - let entries: DrainOrderEntry[]; - let modeLabel: string; + // Shared effective-order resolver: same semantics the quota Pool section and + // the selector use (sort by on-disk priority, surface drift). + const effective = resolveEffectiveDrainOrder(provider, accounts); - // Manual mode is only usable when at least one stored ID still maps to a live - // account. If every stored ID is stale, fall back to the file-order view. - let manualValidIds: string[] | undefined; - if (drainCfg?.mode === 'manual' && drainCfg.orderedIds && drainCfg.orderedIds.length > 0) { - const existingIds = new Set(accounts.map((a) => a.accountId)); - manualValidIds = drainCfg.orderedIds.filter((id) => existingIds.has(id)); - } - - if (manualValidIds && manualValidIds.length > 0) { - entries = computeManualDrainOrder(manualValidIds, accounts); - modeLabel = `manual (${color('--set', 'command')})`; - } else if (drainCfg?.mode === 'tier') { - entries = computeTierDrainOrder(accounts); - modeLabel = `tier-derived (${color('--by-tier', 'command')})`; - } else { + if (effective.mode === 'file') { // File order: no priority writes; show stable file order. Reached when no // config is stored, or when a stored manual order has only stale IDs. printFileOrderView(provider, accounts); return; } - annotateCurrentPriorities(entries); - - // Detect drift: any account where the computed config priority does not match - // what is actually on disk (the selector's real input). Missing file priority - // (undefined) treated as 0 by the selector, so it also counts as drift when - // the config says something higher. - const hasDrift = entries.some((e) => (e.currentPriority ?? 0) !== e.priority); + const entries = effective.entries; + const modeLabel = + effective.mode === 'manual' + ? `manual (${color('--set', 'command')})` + : `tier-derived (${color('--by-tier', 'command')})`; + const hasDrift = effective.hasDrift; console.log(` Mode: ${modeLabel}`); if (hasDrift) { diff --git a/src/commands/cliproxy/pool-state-renderer.ts b/src/commands/cliproxy/pool-state-renderer.ts new file mode 100644 index 00000000..8c56c4c4 --- /dev/null +++ b/src/commands/cliproxy/pool-state-renderer.ts @@ -0,0 +1,210 @@ +/** + * Pool State Renderer (CLI) + * + * Renders the pool context section appended to `ccs cliproxy quota` per provider: + * - routing mode line (pool on/off, strategy, session affinity) + * - effective drain order (Phase 4 resolver) + * - per-account state: available / cooling-until-