feat(cliproxy): pool routing defaults and safety rails

- pool opt-in writes disable-cooling: false, routing.strategy fill-first,
  session-affinity on (1h TTL), max-retry-credentials 3; all emissions
  pool-gated so non-pool generated config stays content-identical
- cooling re-enable is safe on current CLIProxy binaries: the v5
  disable-cooling workaround targeted upstream cooldown bugs fixed by
  Apr 2026 (see plan archaeology report)
- informed-consent prompt at the 1->2 account-add transition enumerates
  every provider with multiple accounts (instance-global effect) and is
  gated per provider on spike-verified limit signals
- disablePoolRouting restores the non-pool config including
  disable-cooling: true and prints single-account rollback guidance
- cross-lane guard warns when a pool account email is also active in a
  native Claude profile (concurrency is the documented ban vector)
- routing strategy and affinity subcommands warn when pool routing
  manages those keys

Part of #1464 account pools (phase 3).
This commit is contained in:
Tam Nhu Tran
2026-06-11 00:12:14 -04:00
parent a257016ebb
commit fdb043083f
11 changed files with 2071 additions and 10 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
/**
* Cross-lane email overlap guard
*
* The documented ban vector: one Google/Anthropic account active in BOTH a
* CLIProxy OAuth lane AND a native Claude Code profile lane simultaneously.
* CLIProxy refreshes tokens server-side while the native profile may be logged
* in via the same account, creating concurrent token usage patterns that
* Google/Anthropic treat as suspicious.
*
* Scope of check: compare the newly registered CLIProxy account email against
* the email of the currently active native Claude Code profile (via `claude
* auth status`). The profiles.json v3.0 schema removed the email field,
* so we rely on the live auth status command which is already used by
* quota-fetcher-claude.ts.
*
* This guard is advisory only: it warns on stderr but does not block the add.
* The user may intentionally separate accounts; a false positive is less harmful
* than silently allowing a true overlap.
*/
import { warn } from '../../utils/ui';
import { getClaudeAuthStatus } from '../../utils/claude-detector';
import { maskEmail } from './account-safety';
import type { CLIProxyProvider } from '../types';
/** Providers where CLIProxy OAuth could create a cross-lane conflict with native Claude */
const CROSS_LANE_RISK_PROVIDERS: CLIProxyProvider[] = ['claude', 'agy', 'gemini', 'codex'];
/**
* Check whether the newly added CLIProxy account email matches the email of
* the currently active native Claude Code profile.
*
* Emits a warning to stderr if an overlap is detected. Silent on errors
* (CLI not found, not logged in, etc.) — the check is best-effort.
*
* @param provider - The CLIProxy provider being added
* @param email - Email address of the account that was just registered
*/
export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: string): void {
if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return;
try {
const status = getClaudeAuthStatus();
if (!status?.loggedIn || !status.email) return;
const normalized = email.toLowerCase().trim();
const nativeNormalized = status.email.toLowerCase().trim();
if (normalized !== nativeNormalized) return;
const masked = maskEmail(email);
const nativeMasked = maskEmail(status.email);
console.error('');
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
console.error(` CLIProxy account: ${masked} (${provider})`);
console.error(` Native Claude Code profile: ${nativeMasked} (logged in)`);
console.error(
' Same account active in both CLIProxy and native Claude lanes is a known ban risk.'
);
console.error(
' CLIProxy refreshes tokens server-side; native Claude may do the same concurrently.'
);
console.error(' If you want to keep access, use separate accounts for each lane.');
console.error(
' CCS is provided as-is and cannot take responsibility for access-loss decisions.'
);
console.error('');
} catch {
// Silent: CLI not installed, spawn failed, JSON parse error, etc.
}
}
+36
View File
@@ -75,6 +75,8 @@ import {
warnOAuthBanRisk,
warnPossible403Ban,
} from '../accounts/account-safety';
import { maybeOfferPoolRouting } from '../routing/pool-opt-in-prompt';
import { checkCrossLaneEmailOverlap } from '../accounts/account-safety-cross-lane';
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
import { InteractivePrompt } from '../../utils/prompt';
import { getCcsDir } from '../../utils/config-manager';
@@ -1133,6 +1135,8 @@ export async function triggerOAuth(
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
// Capture count before registration for 1->2 transition detection
const accountCountBeforeAdd = existingAccounts.length;
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
const nicknameError = !fromUI
@@ -1363,6 +1367,38 @@ export async function triggerOAuth(
}
if (account) {
// Cross-lane overlap guard: warn if this account's email is also active
// in native Claude profiles (same account in two lanes is the documented ban vector).
if (account.email) {
checkCrossLaneEmailOverlap(provider, account.email);
}
// Pool routing opt-in: offer at the 1->2 account-add transition for verified providers.
// Only runs for local CLI sessions — skip when fromUI is true because the dashboard
// calls triggerOAuth from an HTTP request handler; the server may be running in a
// foreground terminal (ccs api / ccs dashboard) where process.stdin.isTTY is true,
// so reaching InteractivePrompt.confirm would block the HTTP request on the server's
// stdin and show the consent prompt to the wrong audience.
// Dashboard parity for the opt-in belongs to Phase 6.
if (!fromUI) {
try {
await maybeOfferPoolRouting(provider, accountCountBeforeAdd);
} catch (promptErr) {
// A regenerateConfig or prompt failure must not fail triggerOAuth after a
// successful account registration — the account is already registered.
logger.stage(
'auth',
'cliproxy.pool-prompt.error',
'Pool routing prompt failed (non-fatal)',
{ provider },
{ level: 'warn' }
);
if (process.env.CCS_DEBUG) {
console.error('[!] Pool routing prompt error (non-fatal):', promptErr);
}
}
}
logger.stage(
'auth',
'cliproxy.oauth.success',
+69 -9
View File
@@ -44,8 +44,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v17: Persist routing.strategy from CCS unified config
* v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config
* v19: Persist backend-aware management panel repository from CCS unified config
* v20: Pool-gated cooling/routing/retry-cap block; disable-cooling flips to false for pool users
*/
export const CLIPROXY_CONFIG_VERSION = 19;
export const CLIPROXY_CONFIG_VERSION = 20;
export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY =
'https://github.com/router-for-me/Cli-Proxy-API-Management-Center';
@@ -138,6 +139,45 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } {
};
}
/**
* Check whether pool routing is enabled in the CCS unified config.
*
* Cooling archaeology (CCS v5, commit fb77d72a, Jan 26 2026):
* disable-cooling: true was added for stability because single-account users
* hit a blackout cliff when their only credential entered cooldown. Two upstream
* bugs compounded the issue:
* 1. Transient-error blackout (fixed upstream Jan 21 2026, commit 30a59168)
* 2. 401/402/403/404 ignoring the disable-cooling flag entirely
* (fixed upstream Apr 7 2026, commit 0ea76801)
*
* The concern no longer applies on current CLIProxy versions (fallback 6.9.45,
* which postdates both fixes). For pool users (2+ accounts per provider)
* cooling-ON is the correct behavior: a suspended credential rotates out and
* a healthy one takes over, which is the pool-routing value proposition.
* For single-account users the original stability concern still holds, so
* disable-cooling: true is preserved when pool routing is disabled.
*
* Per-auth metadata override cannot be used to enable cooling — the upstream
* override is "true-only" (types.go:384-404): a false per-auth value falls back
* to the global flag. The flip must be at the top-level config key.
*
* Retry-cap note: max-retry-credentials is ONLY valid with cooling ON.
* Without cooling, a just-exhausted credential is still "available" on the
* next request; retry-cap would not prevent re-targeting it.
*/
function isPoolRoutingEnabled(): boolean {
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true;
}
/**
* Get max-retry-credentials for pool routing config.
* Only consulted when pool routing is enabled.
* Defaults to 3 (try up to 3 credentials before returning 429 to caller).
*/
function getPoolMaxRetryCredentials(): number {
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.max_retry_credentials ?? 3;
}
function getRoutingStrategy(): 'round-robin' | 'fill-first' {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
@@ -615,6 +655,7 @@ function generateUnifiedConfigContent(
// Get logging settings from user config (disabled by default)
const { loggingToFile, requestLog } = getLoggingSettings();
const poolEnabled = isPoolRoutingEnabled();
const routingStrategy = getRoutingStrategy();
const sessionAffinityEnabled = getSessionAffinityEnabled();
const sessionAffinityTtl = getSessionAffinityTtl();
@@ -632,6 +673,29 @@ function generateUnifiedConfigContent(
);
const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
// Pool routing block: emitted only when pool routing is enabled.
// When pool routing is off, disable-cooling stays true (v5 stability default).
// See isPoolRoutingEnabled() and the cooling archaeology comment above it.
//
// Hot-reload note: CLIProxy watches config for changes and hot-reloads it
// (server.go calls auth.SetQuotaCooldownDisabled on config update).
// Changing pool routing state writes a new config; CLIProxy will pick it up
// live and evict all SessionAffinity pins on the next request (one recompute
// per conversation). A restart is not required.
const poolMaxRetry = poolEnabled ? getPoolMaxRetryCredentials() : null;
const disableCoolingValue = poolEnabled ? 'false' : 'true';
const coolingComment = poolEnabled
? '# Pool routing enabled: cooling ON so exhausted accounts enter backoff and rotate out.\n# First 429 gets a 1s backoff (exponential to 30m cap); Retry-After header is honored.\n# Retry-cap below stops burn loops from retrying already-known-bad credentials.'
: '# Disable quota cooldown scheduling for stability.\n# Pool routing is off: cooling stays disabled to prevent single-account blackouts.\n# Re-enabled automatically when pool routing is turned on (ccs cliproxy pool --enable).';
const poolRoutingBlock = poolEnabled
? `\n# Max credentials to try per request before returning 429 to caller.\n# ONLY valid with cooling on (above). Without cooling a just-exhausted credential\n# remains "available" and retry-cap would not prevent re-targeting it.\nmax-retry-credentials: ${poolMaxRetry}\n`
: '';
const routingBlock = `# Credential selection strategy when multiple matching accounts are available
routing:
strategy: ${poolEnabled ? 'fill-first' : routingStrategy}
session-affinity: ${poolEnabled ? 'true' : sessionAffinityEnabled}
session-affinity-ttl: "${poolEnabled ? '1h' : sessionAffinityTtl}"`;
// Unified config with enhanced CLIProxyAPI features
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
@@ -683,24 +747,20 @@ remote-management:
# Reliability & Quota Management
# =============================================================================
# Disable quota cooldown scheduling for stability
disable-cooling: true
${coolingComment}
disable-cooling: ${disableCoolingValue}
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
request-retry: 0
max-retry-interval: 0
${poolRoutingBlock}
# Auto-switch accounts on quota exceeded (429)
# This enables seamless multi-account rotation when rate limited
quota-exceeded:
switch-project: true
switch-preview-model: true
# Credential selection strategy when multiple matching accounts are available
routing:
strategy: ${routingStrategy}
session-affinity: ${sessionAffinityEnabled}
session-affinity-ttl: "${sessionAffinityTtl}"
${routingBlock}
# =============================================================================
# Authentication
+236
View File
@@ -0,0 +1,236 @@
/**
* Pool routing opt-in prompt
*
* Fires at the 1->2 account-add transition for verified providers (claude, agy).
* Informed-consent copy: discloses instance-global effect and lists ALL providers
* with >=2 accounts that will be affected.
*
* Codex/gemini get no prompt until failover behavior is verified for pool routing
* (spike Test D pending). They continue with implicit round-robin.
*
* Remote/Docker targets: prompt replaced by manual-config hint because session
* affinity is not remotely toggleable from CCS (PR #1117 precedent).
*/
import { info, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { getProxyTarget } from '../proxy/proxy-target-resolver';
import {
enablePoolRouting,
POOL_ROUTING_VERIFIED_PROVIDERS,
POOL_MAX_RETRY_CREDENTIALS,
} from './routing-strategy';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
import { CLIPROXY_DEFAULT_PORT } from '../config/port-manager';
import { getConfigPathForPort } from '../config/path-resolver';
import { getAuthDir } from '../config/path-resolver';
import type { CLIProxyProvider } from '../types';
import { getProviderAccounts } from '../accounts/account-manager';
import { loadAccountsRegistry } from '../accounts/registry';
/**
* Collect names of all providers that currently have >= 2 accounts registered.
* Derived from the registry's actual provider keys so this list can never drift
* from the CLIProxyProvider type union (spec requirement: disclose ALL affected
* providers, not just a hardcoded subset).
*/
function getMultiAccountProviders(): CLIProxyProvider[] {
const result: CLIProxyProvider[] = [];
try {
const registry = loadAccountsRegistry();
for (const p of Object.keys(registry.providers) as CLIProxyProvider[]) {
try {
if (getProviderAccounts(p).length >= 2) result.push(p);
} catch {
// Provider registry entry present but accounts unreadable — skip
}
}
} catch {
// Registry unreadable (first-run, corrupt) — return empty; caller falls back to provider param
}
return result;
}
/**
* Whether the pool routing opt-in prompt has been permanently dismissed.
* Dismissal is recorded per-provider in pool_routing.prompt_dismissed.
*/
export function isPoolPromptDismissed(): boolean {
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.prompt_dismissed === true;
}
/**
* Mark the pool routing prompt as permanently dismissed (user said no explicitly).
*/
export function dismissPoolPrompt(): void {
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
prompt_dismissed: true,
};
});
}
/**
* Show the remote/Docker hint instead of an interactive prompt.
* Session affinity is not remotely toggleable from CCS (fail-closed
* per PR #1117 precedent) so we emit guidance only.
*/
function printRemoteHint(provider: CLIProxyProvider): void {
console.log('');
console.log(
info(`[i] Pool routing hint: you now have 2+ ${provider} accounts on a remote/Docker CLIProxy.`)
);
console.log(
' CCS cannot toggle session affinity on remote targets yet (management API limitation).'
);
console.log(' To enable pool routing manually, add to your CLIProxy config.yaml:');
console.log(' disable-cooling: false');
console.log(` max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS}`);
console.log(' routing:');
console.log(' strategy: fill-first');
console.log(' session-affinity: true');
console.log(' session-affinity-ttl: "1h"');
console.log('');
}
export interface PoolOptInResult {
/** Whether the prompt was shown */
prompted: boolean;
/** Whether pool routing was enabled */
enabled: boolean;
/** Whether the prompt was skipped (remote, dismissed, not verified, already enabled) */
skipped: boolean;
skipReason?: string;
}
/**
* Offer pool routing opt-in when the account count crosses 1->2 for a verified provider.
*
* Call this immediately after a successful account registration when the provider
* transitions from 1 to 2 accounts. The function is a no-op when:
* - Pool routing is already enabled
* - Provider is not in the verified pool list (codex, gemini, etc.)
* - The prompt was previously dismissed
* - Target is non-TTY (piped input / CI)
*
* Remote/Docker targets get a manual-config hint instead of an interactive prompt.
*
* @param provider - The provider that just reached 2 accounts
* @param accountCountBefore - Number of accounts before this add (should be 1 for transition)
* @param port - CLIProxy port (default: 8317)
*/
export async function maybeOfferPoolRouting(
provider: CLIProxyProvider,
accountCountBefore: number,
port: number = CLIPROXY_DEFAULT_PORT
): Promise<PoolOptInResult> {
// Fast path: only fire at the 1->2 transition (accountCountBefore check only).
// The actual post-add count is verified below, after cheap early-exit guards pass,
// so that provider/dismissed/remote guards still return their expected skipReason
// regardless of whether the registry has been populated in the test environment.
if (accountCountBefore !== 1) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
}
// Only for providers where pool routing is verified
if (!POOL_ROUTING_VERIFIED_PROVIDERS.has(provider)) {
return {
prompted: false,
enabled: false,
skipped: true,
skipReason: `provider-${provider}-unverified`,
};
}
// No-op if already enabled
const config = loadOrCreateUnifiedConfig();
if (config.cliproxy?.pool_routing?.enabled === true) {
return { prompted: false, enabled: true, skipped: true, skipReason: 'already-enabled' };
}
// No-op if user already dismissed
if (isPoolPromptDismissed()) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'dismissed' };
}
// Remote / Docker target: print hint, do not prompt
const target = getProxyTarget();
if (target.isRemote) {
printRemoteHint(provider);
return { prompted: false, enabled: false, skipped: true, skipReason: 'remote-target' };
}
// Non-TTY (piped input / CI): skip silently
if (!process.stdin.isTTY || !process.stderr.isTTY) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' };
}
// Verify the actual post-add count is >= 2. registerAccount deduplicates by
// email/token-file so re-authenticating the single existing account keeps the
// count at 1 — not a real 1->2 transition. Checking here (after TTY and remote
// guards) ensures the re-auth path correctly falls back to not-at-transition
// without triggering the prompt for a single-account user.
const accountCountAfter = getProviderAccounts(provider).length;
if (accountCountAfter < 2) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
}
// Gather all providers with 2+ accounts for disclosure
const multiAccountProviders = getMultiAccountProviders();
const providerList =
multiAccountProviders.length > 0 ? multiAccountProviders.join(', ') : provider;
console.log('');
console.log(warn('Pool routing: you now have 2+ accounts for ' + provider));
console.log('');
console.log(' CCS can enable pool routing (fill-first + session affinity + 429 cooldown).');
console.log(' This is an INSTANCE-GLOBAL change: it affects account selection for ALL');
console.log(` CLIProxy providers on this machine: ${providerList}`);
console.log('');
console.log(' What changes:');
console.log(' - disable-cooling: false (cooldown is required for retry-cap to work)');
console.log(' A 429 suspends a credential briefly (1s -> 30m exp backoff).');
console.log(' A 401/403 suspends it for 30 minutes (correct: broken auth = no traffic).');
console.log(' - routing: fill-first (drain one account before switching)');
console.log(' - session-affinity: true (TTL 1h, pinned per conversation)');
console.log(
` - max-retry-credentials: ${POOL_MAX_RETRY_CREDENTIALS} (stop after ${POOL_MAX_RETRY_CREDENTIALS} attempts per request)`
);
console.log('');
console.log(' You can roll back at any time: ccs cliproxy pool --disable');
console.log(' (Or re-enable later: ccs cliproxy pool --enable)');
console.log('');
const yes = await InteractivePrompt.confirm(
' Enable pool routing for all CLIProxy providers?',
{ default: false }
);
if (!yes) {
// Persist decline so we don't re-ask on every subsequent add
dismissPoolPrompt();
console.log(
info(
" Declined. Pool routing stays off. Run 'ccs cliproxy pool --enable' to opt in later."
)
);
console.log('');
return { prompted: true, enabled: false, skipped: false };
}
const configPath = getConfigPathForPort(port);
const authDir = getAuthDir();
const result = enablePoolRouting(port, { configPath, authDir });
console.log('');
if (result.changed) {
console.log(info(result.message));
}
console.log('');
// User accepted and enablePoolRouting completed: pool routing is enabled
// even when this call was an idempotent no-op (changed=false).
return { prompted: true, enabled: true, skipped: false };
}
+239
View File
@@ -1,3 +1,5 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { regenerateConfig } from '../config/generator';
import { getAuthDir, getConfigPathForPort } from '../config/path-resolver';
import {
@@ -7,11 +9,248 @@ import {
} from './routing-strategy-http';
import type { CliproxyRoutingStrategy } from '../types';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
import { getInstalledCliproxyVersion } from '../binary-manager';
import { compareVersions } from '../../utils/update-checker';
import { getConfigYamlPath } from '../../config/loader/io-locks';
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL = '1h';
/**
* Pool routing defaults written to config when pool routing is enabled.
* fill-first + session affinity drains one account before using another,
* maximising per-account context depth while honouring cooldown windows.
*/
export const POOL_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'fill-first';
export const POOL_SESSION_AFFINITY_ENABLED = true;
export const POOL_SESSION_AFFINITY_TTL = '1h';
export const POOL_MAX_RETRY_CREDENTIALS = 3;
/**
* Providers for which pool routing is available and the opt-in prompt
* shows the full cooling/routing disclosure. Others (codex, gemini)
* have failover behaviour that is unverified for pool routing; they get
* a softened prompt variant or no prompt until spike Test D confirms.
*/
export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
/**
* Minimum CLIProxy version that supports pool routing keys:
* max-retry-credentials and the cooling flip.
* Older binaries silently ignore unknown keys — pool rails would appear active
* but have no effect. Warn the user at enable time if below this version.
*
* NOTE: Update this constant when upstream first ships these keys.
* Current best estimate based on spec; adjust after spike Test D confirms.
*/
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
export interface EnablePoolRoutingResult {
/** Whether the pool routing state actually changed */
changed: boolean;
/** Whether an existing explicit user routing setting was preserved */
preservedExplicitSetting: boolean;
message: string;
}
export interface DisablePoolRoutingResult {
changed: boolean;
message: string;
}
/**
* Read the raw (pre-defaults-merger) CCS config YAML.
* The loaded config always injects `strategy: round-robin`, `session_affinity: false`,
* `session_affinity_ttl: 1h` as defaults — so we cannot use the merged config to
* detect whether the user actually wrote these keys. This helper reads the raw YAML
* and returns the partial routing block as-written on disk.
*
* Returns null if the config file does not exist or cannot be parsed.
*/
function readRawRoutingConfig(): {
strategy?: unknown;
session_affinity?: unknown;
session_affinity_ttl?: unknown;
} | null {
try {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) return null;
const raw = yaml.load(fs.readFileSync(yamlPath, 'utf8')) as Record<string, unknown> | null;
if (!raw || typeof raw !== 'object') return null;
const cliproxy = raw['cliproxy'] as Record<string, unknown> | undefined;
if (!cliproxy || typeof cliproxy !== 'object') return null;
const routing = cliproxy['routing'] as Record<string, unknown> | undefined;
if (!routing || typeof routing !== 'object') return null;
return routing as {
strategy?: unknown;
session_affinity?: unknown;
session_affinity_ttl?: unknown;
};
} catch {
return null;
}
}
/**
* Detect whether the user has set a routing strategy that differs from the
* injected default (round-robin).
*
* Background: loadOrCreateUnifiedConfig persists injected defaults to disk on
* first load, so `strategy: round-robin` may appear in the raw YAML even on a
* pristine config — it was injected by CCS, not written by the user. A stored
* value EQUAL to the default is therefore treated as NOT explicit so that
* enablePoolRouting does not falsely claim to be "preserving a custom strategy".
*
* A value that DIFFERS from the default (e.g. fill-first) is treated as
* user-managed: preserve it and warn.
*/
export function hasExplicitRoutingStrategy(): boolean {
const rawRouting = readRawRoutingConfig();
if (rawRouting?.strategy === undefined) return false;
// Equal to the injected default -> not explicitly customised by the user
return rawRouting.strategy !== DEFAULT_CLIPROXY_ROUTING_STRATEGY;
}
/**
* Detect whether the user has set session-affinity to a value that differs
* from the injected default (false).
*
* Same rationale as hasExplicitRoutingStrategy: loadOrCreateUnifiedConfig may
* persist `session_affinity: false` as a default, so presence alone is not
* sufficient — only a value that differs from the default counts as explicit.
*/
export function hasExplicitSessionAffinity(): boolean {
const rawRouting = readRawRoutingConfig();
if (rawRouting?.session_affinity === undefined) return false;
// Equal to the injected default -> not explicitly customised by the user
return rawRouting.session_affinity !== DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED;
}
/**
* Enable pool routing: write pool_routing.enabled = true and the canonical
* pool defaults (fill-first, session affinity 1h, max-retry-credentials: 3)
* to the CCS unified config. Regenerates the CLIProxy config.yaml so the
* cooling flip and routing block take effect immediately (CLIProxy hot-reloads).
*
* Explicit user routing settings are preserved and a warning is emitted.
* The pool flag is written regardless — the generator uses fill-first/affinity
* from the pool defaults when pool is enabled, bypassing any stored routing.
*
* Idempotent: calling when already enabled is a no-op.
*/
export function enablePoolRouting(
port: number,
options: { configPath?: string; authDir?: string } = {}
): EnablePoolRoutingResult {
const config = loadOrCreateUnifiedConfig();
const already = config.cliproxy?.pool_routing?.enabled === true;
if (already) {
return {
changed: false,
preservedExplicitSetting: false,
message: 'Pool routing is already enabled.',
};
}
const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity();
// Spec step 3 / architecture: assert minimum CLIProxy version at enable time.
// Stale binaries silently ignore max-retry-credentials and the cooling flip,
// so pool rails would appear active but have no effect. Warn and proceed.
try {
const installedVersion = getInstalledCliproxyVersion();
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
console.warn(
`[!] CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}).\n` +
` The max-retry-credentials and cooling keys may be silently ignored by the running binary.\n` +
` Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`
);
}
} catch {
// Binary not installed yet (first setup) — skip the version check silently
}
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
// Write only the pool flag and retry-cap. User's routing values (strategy,
// session_affinity, session_affinity_ttl) are intentionally left untouched so
// disablePoolRouting can restore them without needing a separate backup.
// The generator uses pool constants (fill-first, affinity 1h) when pool is
// enabled, bypassing whatever is stored in cfg.cliproxy.routing.
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
enabled: true,
max_retry_credentials: POOL_MAX_RETRY_CREDENTIALS,
};
});
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
regenerateConfig(port, { configPath, authDir });
return {
changed: true,
preservedExplicitSetting,
message: preservedExplicitSetting
? '[!] Pool routing enabled. Your existing routing setting is preserved in config.\n The generator uses pool defaults (fill-first, affinity 1h) while pool is active.\n To restore your setting, disable pool routing first: ccs cliproxy pool --disable'
: '[OK] Pool routing enabled. CLIProxy config regenerated with cooling ON,\n fill-first strategy, session affinity 1h, max-retry-credentials 3.\n CLIProxy will hot-reload the change; live session pins will re-pin on\n next request.',
};
}
/**
* Disable pool routing: clear pool_routing.enabled and restore the non-pool
* config defaults (disable-cooling: true, round-robin, no affinity).
* Regenerates the CLIProxy config.yaml.
*
* IMPORTANT: disablePoolRouting MUST explicitly restore routing to round-robin
* and session_affinity to false. Simply clearing pool_routing.enabled is not
* sufficient because the upstream CLIProxy default for disable-cooling is false
* (cooling ON) when the key is absent. Leaving cooling ON for a user who has
* disabled pool routing would reintroduce the single-account blackout that v5
* (commit fb77d72a) fixed.
*
* Idempotent: calling when already disabled is a no-op.
*/
export function disablePoolRouting(
port: number,
options: { configPath?: string; authDir?: string } = {}
): DisablePoolRoutingResult {
const config = loadOrCreateUnifiedConfig();
const wasEnabled = config.cliproxy?.pool_routing?.enabled === true;
if (!wasEnabled) {
return {
changed: false,
message: 'Pool routing is not enabled.',
};
}
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
// Only clear the pool flag — user's routing values (strategy, session_affinity,
// session_affinity_ttl) were never overwritten on enable, so they are naturally
// restored here. The generator emits disable-cooling: true when pool is off.
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
enabled: false,
};
});
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
regenerateConfig(port, { configPath, authDir });
return {
changed: true,
message:
'[OK] Pool routing disabled. CLIProxy config regenerated with cooling disabled (stability mode).\n' +
' Your original routing settings are restored.\n' +
' If you have multiple accounts and want fair distribution, round-robin is active.\n' +
' To avoid cache-burn with large multi-account fleets, consider reducing to 1 account\n' +
' or re-enabling pool routing: ccs cliproxy pool --enable',
};
}
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}+$`);
+3
View File
@@ -63,6 +63,9 @@ export async function showHelp(): Promise<void> {
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
['routing affinity', 'Show local session-affinity status and TTL'],
['routing affinity <on|off> [--ttl <duration>]', 'Toggle local session-affinity settings'],
['pool', 'Show pool routing status (fill-first + affinity + 429 cooldown)'],
['pool --enable', 'Enable pool routing (writes cooling/affinity/retry-cap to config)'],
['pool --disable', 'Disable pool routing and restore non-pool config'],
],
],
[
+6
View File
@@ -48,6 +48,7 @@ import {
handleCatalogReset,
handleCatalogJson,
} from './catalog-subcommand';
import { handlePoolSubcommand } from './pool-subcommand';
/**
* Parse --backend flag from args
@@ -186,6 +187,11 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
if (command === 'pool') {
await handlePoolSubcommand(remainingArgs.slice(1));
return;
}
if (command === 'routing') {
const subcommand = remainingArgs[1];
if (subcommand === 'set') {
+66
View File
@@ -0,0 +1,66 @@
/**
* CLIProxy Pool Routing Subcommand
*
* Handles:
* ccs cliproxy pool --enable Enable pool routing (fill-first + affinity + cooling ON)
* ccs cliproxy pool --disable Disable pool routing and restore non-pool config
* ccs cliproxy pool Show current pool routing state
*/
import { initUI, header, ok, warn, info } from '../../utils/ui';
import { enablePoolRouting, disablePoolRouting } from '../../cliproxy/routing/routing-strategy';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver';
import { hasAnyFlag } from '../arg-extractor';
export async function handlePoolSubcommand(args: string[]): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Pool Routing'));
console.log('');
const port = CLIPROXY_DEFAULT_PORT;
const configPath = getConfigPathForPort(port);
const authDir = getAuthDir();
if (hasAnyFlag(args, ['--enable'])) {
const result = enablePoolRouting(port, { configPath, authDir });
if (result.changed) {
console.log(ok(result.message));
} else {
console.log(info(result.message));
}
console.log('');
return;
}
if (hasAnyFlag(args, ['--disable'])) {
const result = disablePoolRouting(port, { configPath, authDir });
if (result.changed) {
console.log(ok(result.message));
} else {
console.log(info(result.message));
}
console.log('');
return;
}
// Default: show status
const config = loadOrCreateUnifiedConfig();
const enabled = config.cliproxy?.pool_routing?.enabled === true;
const dismissed = config.cliproxy?.pool_routing?.prompt_dismissed === true;
const maxRetry = config.cliproxy?.pool_routing?.max_retry_credentials;
console.log(` Status: ${enabled ? ok('enabled') : warn('disabled')}`);
if (enabled && maxRetry !== undefined) {
console.log(` Max retry: ${maxRetry}`);
}
if (!enabled && dismissed) {
console.log(` Dismissed: ${info('yes (prompt will not re-show)')}`);
}
console.log('');
console.log(` Enable: ccs cliproxy pool --enable`);
console.log(` Disable: ccs cliproxy pool --disable`);
console.log('');
}
+30 -1
View File
@@ -1,4 +1,4 @@
import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui';
import { initUI, header, subheader, color, dim, ok, fail, infoBox, warn } from '../../utils/ui';
import { extractOption } from '../arg-extractor';
import {
applyCliproxyRoutingStrategy,
@@ -9,6 +9,7 @@ import {
readCliproxyRoutingState,
readCliproxySessionAffinityState,
} from '../../cliproxy/routing/routing-strategy';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
function printStrategyGuide(): void {
console.log(subheader('Routing Modes:'));
@@ -134,6 +135,20 @@ export async function handleRoutingSet(args: string[]): Promise<void> {
console.log(header('Update CLIProxy Routing'));
console.log('');
// Pool routing is active: the generator bypasses stored routing and emits
// fill-first/affinity regardless. Storing a strategy here creates a divergence
// between the unified config and the emitted config.yaml — warn the user.
const config = loadOrCreateUnifiedConfig();
if (config.cliproxy?.pool_routing?.enabled === true) {
console.log(
warn(
'[!] Pool routing is active. The stored strategy will not take effect\n' +
' until pool routing is disabled: ccs cliproxy pool --disable'
)
);
console.log('');
}
const result = await applyCliproxyRoutingStrategy(requested);
console.log(ok(`Routing strategy set to ${requested}`));
console.log(` Applied: ${color(result.applied, 'info')}`);
@@ -223,6 +238,20 @@ export async function handleRoutingAffinitySet(args: string[]): Promise<void> {
console.log(header('Update CLIProxy Session Affinity'));
console.log('');
// Pool routing is active: the generator bypasses stored session-affinity and emits
// affinity:true/1h regardless. Storing a value here creates a divergence between
// the unified config and the emitted config.yaml — warn the user.
const affinityConfig = loadOrCreateUnifiedConfig();
if (affinityConfig.cliproxy?.pool_routing?.enabled === true) {
console.log(
warn(
'[!] Pool routing is active. The stored affinity setting will not take effect\n' +
' until pool routing is disabled: ccs cliproxy pool --disable'
)
);
console.log('');
}
const result = await applyCliproxySessionAffinitySettings({
enabled: requested,
ttl,
+32
View File
@@ -122,6 +122,36 @@ export interface CLIProxyRoutingConfig {
session_affinity_ttl?: string;
}
/**
* Pool routing configuration for multi-account CLIProxy rotation.
*
* Pool routing is opt-in at the 1->2 account-add transition.
* When enabled: fill-first strategy, session affinity (1h TTL), cooling ON,
* and max-retry-credentials: 3 are written to the generated CLIProxy config.
*
* Cooling note: disable-cooling flips to false when pool routing is enabled.
* This is intentional — cooling is required for retry-cap to function correctly.
* See archaeology comment in generator.ts (CCS v5 commit fb77d72a).
*/
export interface CLIProxyPoolRoutingConfig {
/**
* Whether pool routing is active for this provider.
* Written by enablePoolRouting(); cleared by disablePoolRouting().
*/
enabled?: boolean;
/**
* Max credentials to try per request before returning 429 to the caller.
* Effective only when pool routing (and therefore cooling) is enabled.
* Defaults to 3 when pool routing is enabled.
*/
max_retry_credentials?: number;
/**
* Whether the user has dismissed the pool routing opt-in prompt.
* Prevents re-prompting after an explicit decline.
*/
prompt_dismissed?: boolean;
}
/**
* CLIProxy configuration section.
*/
@@ -150,4 +180,6 @@ export interface CLIProxyConfig {
auto_sync?: boolean;
/** Routing strategy for multi-account CLIProxy selection */
routing?: CLIProxyRoutingConfig;
/** Pool routing opt-in state and configuration */
pool_routing?: CLIProxyPoolRoutingConfig;
}