feat(cliproxy): pool visibility in quota output and dashboard routing card

- ccs cliproxy quota: per-provider Pool context section with routing
  mode (pool on/off, strategy, affinity, effective retry cap), drain
  order resolved exactly as the selector picks (incl. residual on-disk
  priorities reordering display + drift warning), and per-account state
  named distinctly: available / cooling-until-<time> / paused - these
  produce different client errors so they read differently
- cooling visibility degrades honestly when pool cooling is disabled
  (no cooldown data exists; renderer says so)
- dashboard routing card: pool badge + retry cap + drain-order hint in
  compact and full variants via additive poolRouting state block
- no Bar summary fields, no per-session pin introspection (cut per
  plan); policy_quota_unsupported semantics untouched

Part of #1464 account pools (phase 6).
This commit is contained in:
Tam Nhu Tran
2026-06-11 13:10:31 -04:00
parent a33e12619e
commit 74894f61da
15 changed files with 1254 additions and 48 deletions
@@ -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);
});
});
});
@@ -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<AccountInfo> & { 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<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
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);
});
});
});
+31
View File
@@ -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) {
+132
View File
@@ -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 };
}
+224
View File
@@ -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<CLIProxyProvider>(['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<string, { until: number; pausedAt: string }>,
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<string, { until: number; pausedAt: string }>();
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 };
}
+20
View File
@@ -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
*/
+35
View File
@@ -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<CliproxyRoutin
return strategy;
}
/**
* Read the pool routing mode from the unified config.
* Pool routing is proxy-wide opt-in; this reflects the persisted flag, not a
* live selector probe.
*/
export function getCliproxyPoolRoutingState(): CliproxyPoolRoutingState {
const pool = loadOrCreateUnifiedConfig().cliproxy?.pool_routing;
const enabled = pool?.enabled === true;
return {
enabled,
// When pool routing is enabled the generator applies the POOL_MAX_RETRY_CREDENTIALS
// default, so surface the same effective value here to match the generated
// config. When disabled, max-retry does not apply; leave it unset.
maxRetryCredentials: enabled
? (pool?.max_retry_credentials ?? POOL_MAX_RETRY_CREDENTIALS)
: undefined,
};
}
export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState> {
const target = getCliproxyRoutingTarget();
const poolRouting = getCliproxyPoolRoutingState();
if (target.isRemote) {
return {
@@ -390,6 +422,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
source: 'live',
target: 'remote',
reachable: true,
poolRouting,
};
}
@@ -399,6 +432,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
source: 'live',
target: 'local',
reachable: true,
poolRouting,
};
} catch {
return {
@@ -407,6 +441,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
target: 'local',
reachable: false,
message: 'Local CLIProxy is not reachable. Showing the saved startup default.',
poolRouting,
};
}
}
@@ -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<PoolAccountState> & { 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');
});
});
+4 -1
View File
@@ -56,7 +56,10 @@ export async function showHelp(): Promise<void> {
['default <account>', 'Set default account for rotation'],
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', '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 <name>', `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'],
+16 -47
View File
@@ -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<CLIProxyProvider>(['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<void> {
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) {
@@ -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-<time> / paused
*
* ASCII only. No color is required for correctness; color is applied via the ui
* helpers, which already degrade to plain text under NO_COLOR / non-TTY.
*/
import { subheader, color, dim, warn } from '../../utils/ui';
import {
resolvePoolState,
POOL_TIER_AWARE_PROVIDERS,
type PoolAccountState,
type PoolRoutingSettings,
type PoolDrainOrderMode,
} from '../../cliproxy/accounts/pool-state';
import {
getConfiguredCliproxyRoutingStrategy,
getConfiguredCliproxySessionAffinitySettings,
POOL_MAX_RETRY_CREDENTIALS,
} from '../../cliproxy/routing/routing-strategy';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import type { CLIProxyProvider } from '../../cliproxy/types';
/**
* Read pool routing settings from the unified config. Pool routing, when on,
* uses fill-first + session affinity regardless of stored routing values, so the
* effective routing shown reflects the pool defaults in that case.
*/
export function readPoolRoutingSettings(): PoolRoutingSettings {
const config = loadOrCreateUnifiedConfig();
const poolEnabled = config.cliproxy?.pool_routing?.enabled === true;
const maxRetryCredentials = config.cliproxy?.pool_routing?.max_retry_credentials;
if (poolEnabled) {
// Pool routing overrides routing to fill-first + affinity 1h (see generator).
return {
poolEnabled: true,
strategy: 'fill-first',
sessionAffinityEnabled: true,
sessionAffinityTtl: '1h',
maxRetryCredentials,
};
}
const affinity = getConfiguredCliproxySessionAffinitySettings();
return {
poolEnabled: false,
strategy: getConfiguredCliproxyRoutingStrategy(),
sessionAffinityEnabled: affinity.enabled,
sessionAffinityTtl: affinity.ttl,
maxRetryCredentials,
};
}
/** Format an epoch-ms reset time as a short relative + clock label. */
export function formatCooldownReset(until: number, now: number): string {
const seconds = Math.max(0, Math.round((until - now) / 1000));
const relative =
seconds <= 0
? 'now'
: seconds < 60
? `${seconds}s`
: seconds < 3600
? `${Math.round(seconds / 60)}m`
: seconds < 86400
? `${Math.round(seconds / 3600)}h`
: `${Math.round(seconds / 86400)}d`;
const clock = new Date(until).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
return `${relative} (${clock})`;
}
/**
* Human label + tone for a single account state. The three states map to
* distinct client failure modes, so they are labelled differently on purpose.
*/
export function describeAccountState(
account: PoolAccountState,
now: number
): { label: string; tone: 'available' | 'cooling' | 'paused' } {
switch (account.state) {
case 'cooling': {
const reset =
account.cooldownUntil !== undefined
? formatCooldownReset(account.cooldownUntil, now)
: 'unknown';
const src = account.cooldownSource === 'memory' ? ' [in-process]' : '';
return { label: `cooling until ${reset}${src}`, tone: 'cooling' };
}
case 'paused':
return { label: 'paused (manual)', tone: 'paused' };
case 'available':
default:
return { label: 'available', tone: 'available' };
}
}
export function modeLabel(mode: PoolDrainOrderMode): string {
switch (mode) {
case 'manual':
return 'manual (--set)';
case 'tier':
return 'tier-derived (--by-tier)';
case 'file':
default:
return 'file order';
}
}
/**
* Render the pool context section for one provider. Returns silently (renders
* nothing) when the provider has no accounts, so quota output stays clean.
*/
export function renderProviderPoolSection(
provider: CLIProxyProvider,
settings: PoolRoutingSettings,
now: number = Date.now()
): void {
const pool = resolvePoolState({ provider, settings, now });
if (pool.states.length === 0) {
return;
}
console.log(subheader('Pool context'));
// Routing mode line. The badge is a plain success-colored label (no marker)
// so it reads as a status, not an action item.
const routingBadge = settings.poolEnabled
? color('pool routing ON', 'success')
: dim('pool routing off');
const affinity = settings.sessionAffinityEnabled
? `affinity ${settings.sessionAffinityTtl}`
: 'no affinity';
// max-retry only applies when pool routing is on; mirror the generator's
// default so the displayed value matches the generated config.
const retry = settings.poolEnabled
? `, max-retry ${settings.maxRetryCredentials ?? POOL_MAX_RETRY_CREDENTIALS}`
: '';
console.log(` Routing: ${routingBadge} | ${settings.strategy} | ${affinity}${retry}`);
// Drain order line. Order is sorted by the on-disk priority the selector
// actually reads, so the first account here is the real "next" account.
const orderMode = modeLabel(pool.drainOrder.mode);
if (pool.drainOrder.order.length > 0) {
console.log(` Order: ${dim(orderMode)} -> ${pool.drainOrder.order.join(' > ')}`);
} else {
console.log(` Order: ${dim(orderMode)} ${dim('(no active accounts)')}`);
}
// Drift: stored config order diverges from what is on disk. Re-auth drops the
// priority attribute and --reset leaves residuals, so warn instead of showing
// a "next account" that silently disagrees with the selector.
if (pool.drainOrder.hasDrift) {
console.log(
` ${warn(
`Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.`
)}`
);
}
// Per-account state. Render in drain order first, then any accounts not in the
// order (paused accounts are excluded from the order but still listed).
const stateById = new Map(pool.states.map((s) => [s.accountId, s]));
const ordered: PoolAccountState[] = [];
for (const id of pool.drainOrder.order) {
const s = stateById.get(id);
if (s) ordered.push(s);
}
for (const s of pool.states) {
if (!pool.drainOrder.order.includes(s.accountId)) ordered.push(s);
}
for (const account of ordered) {
const { label, tone } = describeAccountState(account, now);
// Color-only labels (no markers) so the per-account list reads as a clean
// status column. Available is success-colored; cooling and paused are
// warning-colored to flag them without prefixing each line with a marker.
const rendered = tone === 'available' ? color(label, 'success') : color(label, 'warning');
const defaultMark = account.isDefault ? color(' *', 'success') : '';
console.log(` - ${account.accountId}${defaultMark}: ${rendered}`);
}
// Honest note when cooling is impossible to observe (cooling flip off).
if (!settings.poolEnabled) {
const anyCooling = pool.states.some((s) => s.state === 'cooling');
if (!anyCooling) {
console.log(
dim(
' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.'
)
);
}
}
// Reset/management hints. Dim hint matches the order subcommand's "To update:"
// style, and the indent stays outside the styling helper.
const tierHint = POOL_TIER_AWARE_PROVIDERS.has(provider) ? ` (or --by-tier)` : '';
console.log(
` ${dim(`Manage order: ccs cliproxy accounts order ${provider} --set <ids>${tierHint}`)}`
);
console.log('');
}
@@ -34,6 +34,7 @@ import type {
QuotaErrorMetadata,
} from '../../cliproxy/quota/quota-types';
import { isOnCooldown } from '../../cliproxy/quota/quota-manager';
import { renderProviderPoolSection, readPoolRoutingSettings } from './pool-state-renderer';
import { CLIProxyProvider } from '../../cliproxy/types';
import {
QUOTA_SUPPORTED_PROVIDER_IDS,
@@ -892,6 +893,9 @@ export async function handleQuotaStatus(
console.log('');
// Pool routing settings are global to the CLIProxy config; read once.
const poolSettings = readPoolRoutingSettings();
for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) {
if (!shouldFetch(provider)) {
continue;
@@ -901,6 +905,9 @@ export async function handleQuotaStatus(
const result = providerResults.get(provider) ?? null;
if (result !== null && runtime.hasData(result)) {
runtime.render(result);
// Pool context: drain order + per-account state (available/cooling/paused).
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
continue;
}
@@ -46,6 +46,8 @@ export function RoutingGuidanceCard({
}: RoutingGuidanceCardProps) {
const { t } = useTranslation();
const currentStrategy = state?.strategy ?? 'round-robin';
const poolEnabled = state?.poolRouting?.enabled ?? false;
const poolMaxRetry = state?.poolRouting?.maxRetryCredentials;
const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
@@ -172,6 +174,29 @@ export function RoutingGuidanceCard({
</div>
</div>
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
<div className="min-w-0">
<div className="text-[10px] font-medium text-foreground">
{t('routingGuidance.poolRouting')}
</div>
<div className="text-[10px] text-muted-foreground">
{poolEnabled && poolMaxRetry !== undefined
? t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })
: t('routingGuidance.drainOrderHint')}
</div>
</div>
<Badge
variant={poolEnabled ? 'secondary' : 'outline'}
title={
poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
}
>
{poolEnabled ? t('routingGuidance.poolRoutingOn') : t('routingGuidance.poolRoutingOff')}
</Badge>
</div>
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
<div className="min-w-0">
<div className="text-[10px] font-medium text-foreground">
@@ -240,11 +265,32 @@ export function RoutingGuidanceCard({
<Badge variant="secondary">{currentStrategy}</Badge>
{state ? <Badge variant="outline">{sourceLabel}</Badge> : null}
{state ? <Badge variant="outline">{state.target}</Badge> : null}
{state ? (
<Badge
variant={poolEnabled ? 'secondary' : 'outline'}
title={
poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
}
>
{t('routingGuidance.poolRouting')}:{' '}
{poolEnabled
? t('routingGuidance.poolRoutingOn')
: t('routingGuidance.poolRoutingOff')}
{poolEnabled && poolMaxRetry !== undefined
? ` · ${t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })}`
: ''}
</Badge>
) : null}
</div>
<p className="max-w-3xl text-xs leading-5 text-muted-foreground">
Proxy-wide account rotation. CCS keeps round-robin as the default until you explicitly
change it.
</p>
<p className="max-w-3xl text-[11px] leading-5 text-muted-foreground">
{t('routingGuidance.drainOrderHint')}
</p>
</div>
<div className="flex flex-col gap-2 xl:items-end">
+7
View File
@@ -509,12 +509,19 @@ export interface AuthStatus {
export type RoutingStrategy = 'round-robin' | 'fill-first';
export interface CliproxyPoolRoutingState {
enabled: boolean;
maxRetryCredentials?: number;
}
export interface CliproxyRoutingState {
strategy: RoutingStrategy;
source: 'live' | 'config';
target: 'local' | 'remote';
reachable: boolean;
message?: string;
/** Pool routing mode (proxy-wide). */
poolRouting?: CliproxyPoolRoutingState;
}
export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
+50
View File
@@ -1917,6 +1917,16 @@ const resources = {
roundRobin: 'Round robin spreads usage.',
fillFirst: 'Fill first keeps backup accounts cold until they are needed.',
routingStrategy: 'Routing strategy',
poolRouting: 'Pool routing',
poolRoutingOn: 'On',
poolRoutingOff: 'Off',
poolRoutingManaged:
'Pool routing is on: CCS manages strategy (fill-first), session affinity, and cooling for the whole proxy.',
poolRoutingOffHint:
'Pool routing is off. Enable it from the CLI (ccs cliproxy pool --enable) for fill-first drain with cooldown.',
poolMaxRetry: 'Max retry {{count}}',
drainOrderHint:
'Per-account drain order and cooldown state: run ccs cliproxy quota or ccs cliproxy accounts order <provider>.',
optionalRouting: 'Optional routing',
sessionAffinity: 'Session affinity',
sessionAffinityOn: 'On',
@@ -4542,6 +4552,16 @@ const resources = {
roundRobin: '轮询模式均匀分配用量。',
fillFirst: '优先填满模式让备用账号保持冷启动直到需要时。',
routingStrategy: '路由策略',
poolRouting: '账号池路由',
poolRoutingOn: '开启',
poolRoutingOff: '关闭',
poolRoutingManaged:
'账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。',
poolRoutingOffHint:
'账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。',
poolMaxRetry: '最大重试 {{count}}',
drainOrderHint:
'查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order <provider>。',
optionalRouting: '可选路由',
sessionAffinity: '会话粘性',
sessionAffinityOn: '开启',
@@ -7230,6 +7250,16 @@ const resources = {
roundRobin: 'Round-robin phân bổ đều lượt dùng.',
fillFirst: 'Fill-first giữ tài khoản dự phòng cho đến khi cần thiết.',
routingStrategy: 'Chiến lược định tuyến',
poolRouting: 'Định tuyến nhóm',
poolRoutingOn: 'Bật',
poolRoutingOff: 'Tắt',
poolRoutingManaged:
'Định tuyến nhóm đang bật: CCS quản lý chiến lược (fill-first), ghim phiên và làm nguội cho toàn bộ proxy.',
poolRoutingOffHint:
'Định tuyến nhóm đang tắt. Bật từ CLI (ccs cliproxy pool --enable) để dùng fill-first kèm làm nguội.',
poolMaxRetry: 'Thử lại tối đa {{count}}',
drainOrderHint:
'Thứ tự rút và trạng thái làm nguội từng tài khoản: chạy ccs cliproxy quota hoặc ccs cliproxy accounts order <provider>.',
optionalRouting: 'Định tuyến tùy chọn',
sessionAffinity: 'Ghim phiên',
sessionAffinityOn: 'Bật',
@@ -10395,6 +10425,16 @@ const resources = {
roundRobin: 'ラウンドロビンで利用を分散します。',
fillFirst: 'Fill first は、バックアップアカウントが必要になるまで待機させます。',
routingStrategy: 'ルーティング戦略',
poolRouting: 'プールルーティング',
poolRoutingOn: 'オン',
poolRoutingOff: 'オフ',
poolRoutingManaged:
'プールルーティングが有効です。CCS がプロキシ全体の戦略(fill-first)、セッション固定、クールダウンを管理します。',
poolRoutingOffHint:
'プールルーティングは無効です。CLIccs cliproxy pool --enable)で有効にすると、クールダウン付きの fill-first を利用できます。',
poolMaxRetry: '最大リトライ {{count}}',
drainOrderHint:
'アカウントごとのドレイン順序とクールダウン状態は、ccs cliproxy quota または ccs cliproxy accounts order <provider> を実行してください。',
optionalRouting: 'オプションのルーティング',
sessionAffinity: 'セッション固定',
sessionAffinityOn: 'オン',
@@ -12657,6 +12697,16 @@ const resources = {
roundRobin: '라운드 로빈은 사용량을 분산합니다.',
fillFirst: '먼저 채우기는 필요할 때까지 백업 계정을 차갑게 유지합니다.',
routingStrategy: '라우팅 전략',
poolRouting: '풀 라우팅',
poolRoutingOn: '켜짐',
poolRoutingOff: '꺼짐',
poolRoutingManaged:
'풀 라우팅이 켜져 있습니다. CCS가 프록시 전체의 전략(fill-first), 세션 어피니티, 쿨다운을 관리합니다.',
poolRoutingOffHint:
'풀 라우팅이 꺼져 있습니다. CLI(ccs cliproxy pool --enable)에서 켜면 쿨다운이 있는 fill-first를 사용할 수 있습니다.',
poolMaxRetry: '최대 재시도 {{count}}',
drainOrderHint:
'계정별 드레인 순서와 쿨다운 상태는 ccs cliproxy quota 또는 ccs cliproxy accounts order <provider>를 실행하세요.',
optionalRouting: '선택적 라우팅',
sessionAffinity: '세션 어피니티',
sessionAffinityOn: '켜짐',