mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
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.
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}+$`);
|
||||
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user