mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
'プールルーティングは無効です。CLI(ccs 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: '켜짐',
|
||||
|
||||
Reference in New Issue
Block a user