Merge pull request #1514 from kaitranntt/kai/feat/1464-account-pools

feat(cliproxy): account pools with auto-continue at usage limits
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-12 11:23:46 -04:00
committed by GitHub
50 changed files with 9235 additions and 82 deletions
+1 -5
View File
@@ -1,10 +1,6 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001"
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed"
}
}
@@ -91,6 +91,12 @@ export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string {
}
function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
// claude is model-neutral: model keys are absent from its base config so that
// Claude Code's own /model selection is respected end-to-end. Return empty
// strings here; createSettingsFile skips writing empty model entries.
if (provider === 'claude') {
return { default: '', opus: '', sonnet: '', haiku: '' };
}
const mapping = getModelMappingFromConfig(provider);
return {
default: mapping.defaultModel,
+18 -8
View File
@@ -109,6 +109,15 @@ function createSettingsFile(
});
const isNative = isAnthropicDirect(baseUrl, apiKey);
// Model-neutral providers (e.g. claude built-in) pass empty strings to signal
// "omit this key". Filter them out so the written settings file does not
// contain ANTHROPIC_MODEL:'' which could be treated as an unintended override.
const modelEnv = {
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
};
const settings = {
env: {
// Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN
@@ -120,10 +129,7 @@ function createSettingsFile(
ANTHROPIC_AUTH_TOKEN: apiKey,
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
}),
ANTHROPIC_MODEL: models.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
...modelEnv,
...(extraModels && extraModels.length > 0
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
: {}),
@@ -202,6 +208,13 @@ function createApiProfileUnified(
});
const isNative = isAnthropicDirect(baseUrl, apiKey);
// Model-neutral providers pass empty strings; omit those keys.
const modelEnvUnified = {
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
};
const settings = {
env: {
...(isNative
@@ -211,10 +224,7 @@ function createApiProfileUnified(
ANTHROPIC_AUTH_TOKEN: apiKey,
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
}),
ANTHROPIC_MODEL: models.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
...modelEnvUnified,
...(extraModels && extraModels.length > 0
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
: {}),
+15 -1
View File
@@ -29,7 +29,11 @@ import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
import { isUnifiedMode } from '../../config/config-loader-facade';
import { isUnifiedMode, hasUnifiedConfig } from '../../config/config-loader-facade';
import {
maybeShowPoolOnboardingHint,
countNativeClaudeProfiles,
} from '../../cliproxy/routing/pool-onboarding-hint';
function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
@@ -310,6 +314,16 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
)
);
console.log('');
// Pool suggestion: shown only AFTER the profile creation fully
// succeeded (a pre-create hint would burn the once-per-install
// dismissal even when creation fails or rolls back). Print-only,
// TTY-gated, never blocks. Gated on hasUnifiedConfig() so legacy
// profiles.json-only installs receive the hint from ccs doctor only
// (where dismissal semantics are preserved). The profile now exists,
// so the registry count is already post-create (no +1 needed).
if (!profileExistedBeforeCreate && hasUnifiedConfig()) {
maybeShowPoolOnboardingHint(countNativeClaudeProfiles());
}
process.exit(0);
} else {
await rollbackFailedCreate();
@@ -0,0 +1,133 @@
/**
* Tests for account-safety ban copy parameterization (Gap 3).
*
* Verifies that handleBanDetection uses provider-appropriate copy:
* - "Anthropic" for the claude provider
* - "Google" for gemini / agy / codex
* and that isBanResponse matches both shared and Anthropic-specific patterns.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describe, expect, it, spyOn, afterEach, beforeEach } from 'bun:test';
import { isBanResponse, handleBanDetection } from '../accounts/account-safety';
// ── isBanResponse ─────────────────────────────────────────────────────────────
describe('isBanResponse', () => {
it.each([
'disabled in this account',
'violation of terms of service',
'account has been disabled',
'account is disabled',
'account has been suspended',
'account has been banned',
])('matches shared ban pattern for all providers: %s', (pattern) => {
expect(isBanResponse(pattern)).toBe(true);
expect(isBanResponse(pattern.toUpperCase())).toBe(true);
// Also matches when provider is specified
expect(isBanResponse(pattern, 'gemini')).toBe(true);
expect(isBanResponse(pattern, 'claude')).toBe(true);
});
it.each(['your account has been blocked', 'account is blocked'])(
'matches Anthropic-specific ban pattern for claude provider: %s',
(pattern) => {
expect(isBanResponse(pattern, 'claude')).toBe(true);
}
);
it.each(['your account has been blocked', 'account is blocked'])(
'does NOT match Anthropic-specific ban pattern for non-claude providers: %s',
(pattern) => {
expect(isBanResponse(pattern, 'gemini')).toBe(false);
expect(isBanResponse(pattern, 'agy')).toBe(false);
expect(isBanResponse(pattern, 'codex')).toBe(false);
// Without provider argument also should not match
expect(isBanResponse(pattern)).toBe(false);
}
);
it('does not match bare "usage policy" substring for any provider (avoids false positives)', () => {
// A rate-limit message mentioning "usage policy" should not trigger ban
const msg = 'Request exceeds usage policy limits for your plan';
expect(isBanResponse(msg)).toBe(false);
expect(isBanResponse(msg, 'gemini')).toBe(false);
expect(isBanResponse(msg, 'claude')).toBe(false);
});
it('returns false for a benign error message', () => {
expect(isBanResponse('rate limit exceeded')).toBe(false);
expect(isBanResponse('network timeout')).toBe(false);
expect(isBanResponse('')).toBe(false);
});
});
// ── handleBanDetection ban copy ────────────────────────────────────────────────
describe('handleBanDetection ban copy', () => {
const stderrLines: string[] = [];
let writeSpy: ReturnType<typeof spyOn>;
let consoleSpy: ReturnType<typeof spyOn>;
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ban-copy-'));
process.env.CCS_HOME = tempHome;
});
const setup = () => {
stderrLines.length = 0;
writeSpy = spyOn(process.stderr, 'write').mockImplementation(
(chunk: string | Uint8Array): boolean => {
stderrLines.push(typeof chunk === 'string' ? chunk : '');
return true;
}
);
// Also capture console.error calls
consoleSpy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
stderrLines.push(String(args[0] ?? ''));
});
};
afterEach(() => {
writeSpy?.mockRestore();
consoleSpy?.mockRestore();
process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('uses "Anthropic" actor copy for the claude provider', () => {
setup();
handleBanDetection('claude', 'test@example.com', 'account has been disabled');
const output = stderrLines.join(' ');
expect(output).toContain('Anthropic');
expect(output).not.toContain('Google');
});
it('uses "Google" actor copy for the gemini provider', () => {
setup();
handleBanDetection('gemini', 'test@example.com', 'account has been disabled');
const output = stderrLines.join(' ');
expect(output).toContain('Google');
expect(output).not.toContain('Anthropic');
});
it('uses "Google" actor copy for the agy provider', () => {
setup();
handleBanDetection('agy', 'test@example.com', 'account has been banned');
const output = stderrLines.join(' ');
expect(output).toContain('Google');
});
it('returns false for a non-ban error (no actor copy emitted)', () => {
setup();
const result = handleBanDetection('claude', 'test@example.com', 'rate limit exceeded');
expect(result).toBe(false);
// No ban message written
expect(stderrLines.join(' ')).not.toContain('Anthropic');
});
});
@@ -0,0 +1,214 @@
/**
* Tests for claude-shadow-warning.ts
*
* Gap 2: shadow warning — emitted once when a user profile named 'claude' or
* 'anthropic' is present; not repeated; not emitted without a collision.
* Gap 4: routing notice — emitted once on first claude provider launch.
*
* These tests use a temp CCS_HOME and a non-TTY stderr mock to confirm
* write behaviour without relying on an interactive terminal.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
// Module under test — imported after CCS_HOME is set up in beforeEach via dynamic re-import.
// Because Bun caches modules, we test the exported functions in isolation by
// controlling the file-system state they depend on.
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
// ── Helpers ───────────────────────────────────────────────────────────────────
function writeLegacyConfig(ccsDir: string, profiles: Record<string, string>): void {
const configPath = path.join(ccsDir, 'config.json');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ profiles }), 'utf-8');
}
function markerDir(ccsDir: string): string {
return path.join(ccsDir, 'cliproxy');
}
function shadowMarker(ccsDir: string): string {
return path.join(markerDir(ccsDir), '.claude-shadow-warned');
}
function routingMarker(ccsDir: string): string {
return path.join(markerDir(ccsDir), '.claude-routing-noticed');
}
// ── Test setup ────────────────────────────────────────────────────────────────
let tempHome: string;
let originalCcsHome: string | undefined;
let stderrLines: string[];
let stderrSpy: ReturnType<typeof spyOn>;
let originalIsTTYDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shadow-warn-'));
process.env.CCS_HOME = tempHome;
// Collect stderr writes without printing them
stderrLines = [];
stderrSpy = spyOn(process.stderr, 'write').mockImplementation(
(chunk: string | Uint8Array): boolean => {
stderrLines.push(typeof chunk === 'string' ? chunk : '');
return true;
}
);
// Save exact isTTY property descriptor so afterEach can restore it precisely
originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
// Simulate a TTY so the TTY guard does not short-circuit
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
stderrSpy.mockRestore();
// Restore isTTY to exactly what it was before the test
if (originalIsTTYDescriptor !== undefined) {
Object.defineProperty(process.stderr, 'isTTY', originalIsTTYDescriptor);
} else {
delete (process.stderr as { isTTY?: boolean }).isTTY;
}
});
// ── Shadow warning (Gap 2) ────────────────────────────────────────────────────
describe('maybeWarnClaudeShadow', () => {
it('does not emit a warning when no shadowed profiles exist', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
maybeWarnClaudeShadow();
expect(stderrLines.join('')).not.toContain('shadowed');
expect(stderrLines.join('')).not.toContain('claude');
});
it('emits a warning when a profile named "claude" exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/claude.settings.json' });
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).toContain('claude');
expect(output).toContain('shadowed');
// Ensure warn() prefix is not doubled ([!] [!] ...)
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
});
it('emits a warning when a profile named "anthropic" exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { anthropic: '/path/to/anthropic.settings.json' });
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).toContain('anthropic');
expect(output).toContain('shadowed');
// Ensure warn() prefix is not doubled
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
});
it('creates the dismissal marker after warning', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
maybeWarnClaudeShadow();
expect(fs.existsSync(shadowMarker(ccsDir))).toBe(true);
});
it('does not repeat the warning when the marker already exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
// Pre-create marker
const mDir = markerDir(ccsDir);
fs.mkdirSync(mDir, { recursive: true });
fs.writeFileSync(shadowMarker(ccsDir), 'already-shown');
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).not.toContain('shadowed');
});
it('does not warn when stderr is not a TTY', () => {
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
maybeWarnClaudeShadow();
expect(stderrLines.join('')).not.toContain('shadowed');
});
it('emits a warning when an account profile named "claude" exists in profiles.json (legacy mode)', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Write an empty settings config (no settings profiles named claude)
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
// Write profiles.json with an account profile named 'claude'
const profilesData = {
version: '2.0.0',
profiles: { claude: { type: 'account', created: new Date().toISOString(), last_used: null } },
default: null,
};
fs.writeFileSync(path.join(ccsDir, 'profiles.json'), JSON.stringify(profilesData), 'utf-8');
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).toContain('claude');
expect(output).toContain('shadowed');
});
});
// ── Routing notice (Gap 4) ────────────────────────────────────────────────────
describe('maybeShowClaudeRoutingNotice', () => {
it('emits routing notice on first call', () => {
maybeShowClaudeRoutingNotice();
const output = stderrLines.join('');
expect(output).toContain('CLIProxy');
// Ensure info() prefix is not doubled ([i] [i] ...)
expect(output).not.toMatch(/\[i\]\s*\[i\]/);
});
it('creates the routing notice marker after first call', () => {
const ccsDir = path.join(tempHome, '.ccs');
maybeShowClaudeRoutingNotice();
expect(fs.existsSync(routingMarker(ccsDir))).toBe(true);
});
it('does not repeat the routing notice when the marker already exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
const mDir = markerDir(ccsDir);
fs.mkdirSync(mDir, { recursive: true });
fs.writeFileSync(routingMarker(ccsDir), 'already-shown');
maybeShowClaudeRoutingNotice();
// No output at all
expect(stderrLines.join('')).not.toContain('CLIProxy');
});
it('does not emit when stderr is not a TTY', () => {
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
maybeShowClaudeRoutingNotice();
expect(stderrLines.join('')).not.toContain('CLIProxy');
});
});
@@ -0,0 +1,505 @@
/**
* Phase 5: Pool Onboarding Hint - Test Suite
*
* Covers:
* 1. Non-TTY: hint is silent
* 2. Pool already enabled: hint is silent
* 3. Dismissed flag: hint is silent
* 4. Fewer than 2 profiles: hint is silent
* 5. Exactly 2 profiles, TTY, not dismissed, pool off: hint prints (single [i] marker)
* 6. After dismiss, second call is silent
* 7. Pre-computed count avoids double registry read
* 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip
* 9. countNativeClaudeProfiles returns only account-type profiles
* 10. Doctor site (simulation): maybeShowPoolOnboardingHint callable after report phase
* 10b. Doctor wiring (static): doctor.ts imports and calls the hint symbol
* 11. Create-command suggestion: hint fires with count=2 when 1 existing profile present
* 12. Legacy-only install: dismissOnboardingHint does not create config.yaml
* 13. Hint copy includes 'ccs claude' entry point and quota trade-off language
* 14. Legacy-only install: hasUnifiedConfig() returns false, enforcing call-site gate
* 15. Malformed config (with count): hint swallows the error, never throws, prints nothing
* 16. Malformed config (no count, doctor path): hint never throws
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
// Non-cache-busted facade import so mutateConfig targets the SHARED singleton
import { invalidateConfigCache as invalidateSharedConfigCache } from '../../config/config-loader-facade';
// ── Helpers ─────────────────────────────────────────────────────────────────
function createTestHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-test-'));
const ccsDir = path.join(dir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
return dir;
}
/** Create a legacy-only home: has profiles.json but NO config.yaml */
function createLegacyTestHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-legacy-'));
const ccsDir = path.join(dir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Intentionally no config.yaml - legacy install
return dir;
}
/** Write a minimal profiles.json with N account-type entries */
function writeProfiles(ccsDir: string, names: string[]): void {
const profiles: Record<string, unknown> = {};
for (const n of names) {
profiles[n] = { type: 'account', created: new Date().toISOString(), last_used: null };
}
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2),
'utf8'
);
}
/** Write a minimal profiles.json with mixed types */
function writeProfilesMixed(ccsDir: string): void {
const profiles: Record<string, unknown> = {
acct1: { type: 'account', created: new Date().toISOString(), last_used: null },
settings1: { type: 'settings', created: new Date().toISOString(), last_used: null },
};
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2),
'utf8'
);
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('Phase 5: Pool Onboarding Hint', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let originalIsTTY: boolean | undefined;
beforeEach(() => {
tempHome = createTestHome();
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
// Default: treat stdout as TTY for hint to fire
originalIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
Object.defineProperty(process.stdout, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
fs.rmSync(tempHome, { recursive: true, force: true });
invalidateSharedConfigCache();
});
// ── 1. Non-TTY ──────────────────────────────────────────────────────────
it('returns skipReason non-tty when stdout.isTTY is false', async () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5nontty=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('non-tty');
});
// ── 2. Pool already enabled ─────────────────────────────────────────────
it('returns skipReason pool-already-enabled when pool routing is on', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
// Enable pool routing first
const { mutateConfig } = await import(`../../config/config-loader-facade?p5pool=${Date.now()}`);
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = { ...(cfg.cliproxy.pool_routing ?? {}), enabled: true };
});
invalidateSharedConfigCache();
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5pool2=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('pool-already-enabled');
});
// ── 3. Dismissed ────────────────────────────────────────────────────────
it('returns skipReason dismissed when onboarding_hint_dismissed is true', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const { mutateConfig } = await import(`../../config/config-loader-facade?p5dis=${Date.now()}`);
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...(cfg.cliproxy.pool_routing ?? {}),
onboarding_hint_dismissed: true,
};
});
invalidateSharedConfigCache();
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5dis2=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('dismissed');
});
// ── 4. Fewer than 2 profiles ────────────────────────────────────────────
it('returns skipReason fewer-than-2-profiles when only 1 profile exists', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work']);
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5few=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('fewer-than-2-profiles');
});
it('returns skipReason fewer-than-2-profiles when no profiles exist', async () => {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5zero=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('fewer-than-2-profiles');
});
// ── 5. Hint prints when 2 profiles, TTY, not dismissed, pool off ────────
it('prints hint and returns printed=true when conditions are met', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5print=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(true);
expect(result.skipReason).toBeUndefined();
// Verify output: exactly one [i] marker (no double-prefix from info() + literal)
expect(consoleSpy.mock.calls.length).toBeGreaterThan(0);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles');
const iMarkerCount = (allOutput.match(/\[i\]/g) ?? []).length;
expect(iMarkerCount).toBe(1);
} finally {
consoleSpy.mockRestore();
}
});
// ── 6. After dismiss, second call is silent ─────────────────────────────
it('is silent on the second call because hint auto-dismisses after first print', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
// Use the same module instance for both calls so the dismissed flag persists
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5once=${Date.now()}`
);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const first = maybeShowPoolOnboardingHint();
expect(first.printed).toBe(true);
invalidateSharedConfigCache();
const second = maybeShowPoolOnboardingHint();
expect(second.printed).toBe(false);
expect(second.skipReason).toBe('dismissed');
} finally {
consoleSpy.mockRestore();
}
});
// ── 7. Pre-computed count skips internal registry read ─────────────────
it('uses the pre-computed count when provided', async () => {
// No profiles.json — but we pass count=3 directly so it should still print
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5precount=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint(3);
expect(result.printed).toBe(true);
} finally {
consoleSpy.mockRestore();
}
});
// ── 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip ─────
it('dismissOnboardingHint persists and isOnboardingHintDismissed reads it', async () => {
const { isOnboardingHintDismissed, dismissOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5dismiss=${Date.now()}`
);
expect(isOnboardingHintDismissed()).toBe(false);
dismissOnboardingHint();
invalidateSharedConfigCache();
expect(isOnboardingHintDismissed()).toBe(true);
});
// ── 9. countNativeClaudeProfiles counts only account-type profiles ───────
it('countNativeClaudeProfiles ignores non-account type profiles', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfilesMixed(ccsDir); // 1 account + 1 settings
const { countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5count=${Date.now()}`
);
expect(countNativeClaudeProfiles()).toBe(1);
});
it('countNativeClaudeProfiles returns 0 when no profiles.json exists', async () => {
const { countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5count2=${Date.now()}`
);
expect(countNativeClaudeProfiles()).toBe(0);
});
// ── 10. Doctor site: maybeShowPoolOnboardingHint is exported and callable ─
it('maybeShowPoolOnboardingHint is callable after the doctor report phase', async () => {
// NOTE: This is a SIMULATION of the doctor post-checks call site, not the
// real Doctor pipeline. The hint fires from Doctor's private displayResults()
// method, which has no cheap standalone entry point - exercising it directly
// would require constructing Doctor and running the full async check pipeline
// (system / OAuth / CLIProxy / Docker), which is heavy and environment-fragile.
// Instead we call the exact exported symbol the doctor imports
// (maybeShowPoolOnboardingHint, see doctor.ts import) under the same
// preconditions: 2 profiles, TTY, not dismissed. The wiring itself - that
// doctor.ts imports this symbol - is asserted in the companion test below.
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5doc=${Date.now()}`
);
// doctor calls maybeShowPoolOnboardingHint() with no pre-computed count
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles');
} finally {
consoleSpy.mockRestore();
}
});
// ── 10b. Doctor wiring: doctor.ts imports and calls the hint symbol ───────
it('doctor.ts wires maybeShowPoolOnboardingHint as the doctor hint site', () => {
// Cheap static wiring assertion: confirms the real doctor call site imports
// and invokes the same symbol the simulation above exercises, without
// executing the heavy Doctor pipeline. If the doctor stops importing or
// calling the hint, this fails - catching a broken hint site that a pure
// simulation test could not.
const doctorSrc = fs.readFileSync(
path.join(__dirname, '..', '..', 'management', 'doctor.ts'),
'utf8'
);
expect(doctorSrc).toContain(
"import { maybeShowPoolOnboardingHint } from '../cliproxy/routing/pool-onboarding-hint'"
);
expect(doctorSrc).toContain('maybeShowPoolOnboardingHint();');
});
// ── 11. Create-command suggestion: hint fires with post-create count ──────
it('hint fires with count 2 after a successful 2nd-profile create (post-create call site)', async () => {
// NOTE: SIMULATION of the create-command call site, not the real command.
// create-command shows the hint only AFTER profile creation succeeds (a
// pre-create hint would burn the once-per-install dismissal on a failed
// create). At that point the registry already contains the new profile,
// so the call is maybeShowPoolOnboardingHint(countNativeClaudeProfiles()).
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']); // post-create state: 2 profiles
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint, countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5create=${Date.now()}`
);
const count = countNativeClaudeProfiles();
expect(count).toBe(2);
const result = maybeShowPoolOnboardingHint(count);
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles');
} finally {
consoleSpy.mockRestore();
}
});
// ── 12. Legacy-only install: dismissal does not create config.yaml ────────
it('dismissOnboardingHint does not create config.yaml for legacy profiles.json-only installs', async () => {
// Override tempHome with a legacy-only home (no config.yaml)
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
const legacyHome = createLegacyTestHome();
process.env.CCS_HOME = legacyHome;
invalidateSharedConfigCache();
try {
const ccsDir = path.join(legacyHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const configYamlPath = path.join(ccsDir, 'config.yaml');
const { dismissOnboardingHint, isOnboardingHintDismissed } = await import(
`../routing/pool-onboarding-hint?p5legacy=${Date.now()}`
);
// Sanity: config.yaml does not exist yet
expect(fs.existsSync(configYamlPath)).toBe(false);
// isOnboardingHintDismissed returns false (reads empty config, no persist)
expect(isOnboardingHintDismissed()).toBe(false);
// dismissOnboardingHint must NOT create config.yaml
dismissOnboardingHint();
expect(fs.existsSync(configYamlPath)).toBe(false);
} finally {
fs.rmSync(legacyHome, { recursive: true, force: true });
invalidateSharedConfigCache();
}
});
// ── 13. Hint copy is opt-in framed and names the enable command ──────────
it("hint copy mentions 'ccs claude' and the opt-in enable command", async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5copy=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('ccs claude');
// Copy must be opt-in (not declarative "already pools") and name the
// actual enable command so the user has an actionable next step.
expect(allOutput).toContain('ccs cliproxy pool --enable');
expect(allOutput.toLowerCase()).toContain('can auto-continue');
// Must NOT read as already-active behavior.
expect(allOutput).not.toContain('pool auto-continues');
} finally {
consoleSpy.mockRestore();
}
});
// ── 14. Legacy-only install: hasUnifiedConfig() is false at account-flow / create-command sites ──
it('hasUnifiedConfig returns false for legacy profiles.json-only installs (call-site gate)', async () => {
// Verifies that the guard used in account-flow.ts and create-command.ts
// (hasUnifiedConfig()) correctly reports false for a profiles.json-only home,
// meaning those call sites silently skip the hint and legacy users receive
// the hint exclusively from ccs doctor.
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
const legacyHome = createLegacyTestHome();
process.env.CCS_HOME = legacyHome;
invalidateSharedConfigCache();
try {
const ccsDir = path.join(legacyHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const { hasUnifiedConfig } = await import(
`../../config/config-loader-facade?p5gate=${Date.now()}`
);
// The call-site guard must return false - legacy install has no config.yaml
expect(hasUnifiedConfig()).toBe(false);
} finally {
fs.rmSync(legacyHome, { recursive: true, force: true });
invalidateSharedConfigCache();
}
});
// ── 15. Malformed config: hint never throws and prints nothing ────────────
it('does not throw or print when config.yaml is malformed (hint must not break launch)', async () => {
// A pre-computed profileCount (passed by the launch / create call sites)
// clears the cheap gates, so the decision reaches the single config load.
// A malformed config.yaml makes loadOrCreateUnifiedConfig throw; the
// try/catch in maybeShowPoolOnboardingHint must swallow it - returning
// printed=false with skipReason 'error' and emitting no hint - so an account
// launch is never broken by a bad config.
const ccsDir = path.join(tempHome, '.ccs');
// Overwrite the valid config.yaml from createTestHome() with invalid YAML.
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8');
invalidateSharedConfigCache();
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5malformed=${Date.now()}`
);
let result: { printed: boolean; skipReason?: string } | undefined;
// Must not throw. Pass count=2 to bypass the registry (which itself loads
// config) and drive the decision into the explicit config load + try/catch.
expect(() => {
result = maybeShowPoolOnboardingHint(2);
}).not.toThrow();
expect(result?.printed).toBe(false);
expect(result?.skipReason).toBe('error');
// No hint line printed.
const hintLines = consoleSpy.mock.calls
.map((c) => String(c[0]))
.filter((s) => s.includes('Claude profiles'));
expect(hintLines.length).toBe(0);
} finally {
consoleSpy.mockRestore();
}
});
// ── 16. Malformed config via doctor path (no count): still no throw ───────
it('does not throw when config is malformed and no profileCount is passed (doctor path)', async () => {
// The doctor site calls maybeShowPoolOnboardingHint() with no count. With a
// malformed config, countNativeClaudeProfiles() swallows the registry error
// and returns 0, short-circuiting at fewer-than-2-profiles. Either way the
// call must never throw - this guards the doctor call site specifically.
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8');
invalidateSharedConfigCache();
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5malformed2=${Date.now()}`
);
let result: { printed: boolean } | undefined;
expect(() => {
result = maybeShowPoolOnboardingHint();
}).not.toThrow();
expect(result?.printed).toBe(false);
const hintLines = consoleSpy.mock.calls
.map((c) => String(c[0]))
.filter((s) => s.includes('Claude profiles'));
expect(hintLines.length).toBe(0);
} finally {
consoleSpy.mockRestore();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,935 @@
/**
* Tests for drain order priority computation, write path, and attribution stability.
*/
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';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-drain-order-'));
try {
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
}
/** Create a minimal auth JSON file in the auth dir. */
function writeAuthFile(
authDir: string,
fileName: string,
fields: Record<string, unknown> = {}
): void {
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
const content = JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2);
fs.writeFileSync(path.join(authDir, fileName), content, { mode: 0o600 });
}
function readAuthFile(authDir: string, fileName: string): Record<string, unknown> {
return JSON.parse(fs.readFileSync(path.join(authDir, fileName), 'utf-8')) as Record<
string,
unknown
>;
}
// ---------------------------------------------------------------------------
// Import helpers (avoid module-level imports that capture paths at load time)
// ---------------------------------------------------------------------------
async function loadDrainOrder() {
return import(`../drain-order?drain-order=${Date.now()}`);
}
async function loadRegistry() {
return import(`../registry?drain-order-registry=${Date.now()}`);
}
async function loadStatsTransformer() {
return import(`../../../cliproxy/services/stats-fetcher?drain-order-stats-fetcher=${Date.now()}`);
}
async function loadOrderSubcommand() {
return import(
`../../../commands/cliproxy/order-subcommand?drain-order-order-subcommand=${Date.now()}`
);
}
// ---------------------------------------------------------------------------
// Priority computation tests
// ---------------------------------------------------------------------------
describe('rankToPriority', () => {
it('maps rank 0 (highest) to total priority', async () => {
const { rankToPriority } = await loadDrainOrder();
expect(rankToPriority(0, 3)).toBe(3);
});
it('maps last rank to MIN_PRIORITY (1)', async () => {
const { rankToPriority, MIN_PRIORITY } = await loadDrainOrder();
expect(rankToPriority(2, 3)).toBe(MIN_PRIORITY);
expect(rankToPriority(2, 3)).toBeGreaterThanOrEqual(1);
});
it('never returns 0', async () => {
const { rankToPriority } = await loadDrainOrder();
for (let total = 1; total <= 5; total++) {
for (let rank = 0; rank < total; rank++) {
expect(rankToPriority(rank, total)).toBeGreaterThanOrEqual(1);
}
}
});
it('single account gets priority 1', async () => {
const { rankToPriority } = await loadDrainOrder();
expect(rankToPriority(0, 1)).toBe(1);
});
});
describe('computeManualDrainOrder', () => {
it('assigns descending priorities to specified accounts', async () => {
const { computeManualDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'a@x.com', tokenFile: 'a.json' },
{ accountId: 'b@x.com', tokenFile: 'b.json' },
{ accountId: 'c@x.com', tokenFile: 'c.json' },
];
const entries = computeManualDrainOrder(['a@x.com', 'b@x.com', 'c@x.com'], accounts);
const byId = new Map(
entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority])
);
expect(byId.get('a@x.com')).toBeGreaterThan(byId.get('b@x.com'));
expect(byId.get('b@x.com')).toBeGreaterThan(byId.get('c@x.com'));
expect(byId.get('c@x.com')).toBeGreaterThanOrEqual(1);
});
it('throws for unknown account ID', async () => {
const { computeManualDrainOrder } = await loadDrainOrder();
const accounts = [{ accountId: 'a@x.com', tokenFile: 'a.json' }];
expect(() => computeManualDrainOrder(['z@x.com'], accounts)).toThrow();
});
it('throws for duplicate account ID in the order list', async () => {
const { computeManualDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'a@x.com', tokenFile: 'a.json' },
{ accountId: 'b@x.com', tokenFile: 'b.json' },
];
expect(() => computeManualDrainOrder(['a@x.com', 'b@x.com', 'a@x.com'], accounts)).toThrow(
/[Dd]uplicate/
);
});
it('unspecified accounts get MIN_PRIORITY', async () => {
const { computeManualDrainOrder, MIN_PRIORITY } = await loadDrainOrder();
const accounts = [
{ accountId: 'a@x.com', tokenFile: 'a.json' },
{ accountId: 'b@x.com', tokenFile: 'b.json' },
];
const entries = computeManualDrainOrder(['a@x.com'], accounts);
const bEntry = entries.find((e: { accountId: string }) => e.accountId === 'b@x.com');
expect(bEntry?.priority).toBe(MIN_PRIORITY);
});
it('is idempotent - same inputs produce same priorities', async () => {
const { computeManualDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'a@x.com', tokenFile: 'a.json' },
{ accountId: 'b@x.com', tokenFile: 'b.json' },
];
const entries1 = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts);
const entries2 = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts);
expect(JSON.stringify(entries1)).toBe(JSON.stringify(entries2));
});
it('partial --set: every specified account has strictly higher priority than every unspecified account', async () => {
// Regression for the tie at priority 1 when last specified == MIN_PRIORITY == unspecified.
// With a pool of 5, specifying 2 should give the last specified priority > 1.
const { computeManualDrainOrder, MIN_PRIORITY } = await loadDrainOrder();
const accounts = [
{ accountId: 'a@x.com', tokenFile: 'a.json' },
{ accountId: 'b@x.com', tokenFile: 'b.json' },
{ accountId: 'c@x.com', tokenFile: 'c.json' },
{ accountId: 'd@x.com', tokenFile: 'd.json' },
{ accountId: 'e@x.com', tokenFile: 'e.json' },
];
const entries = computeManualDrainOrder(['a@x.com', 'b@x.com'], accounts);
const byId = new Map(
entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority])
);
const specifiedPriorities = ['a@x.com', 'b@x.com'].map((id) => byId.get(id) as number);
const unspecifiedPriorities = ['c@x.com', 'd@x.com', 'e@x.com'].map(
(id) => byId.get(id) as number
);
const minSpecified = Math.min(...specifiedPriorities);
const maxUnspecified = Math.max(...unspecifiedPriorities);
expect(minSpecified).toBeGreaterThan(maxUnspecified);
// Unspecified accounts still get MIN_PRIORITY
for (const p of unspecifiedPriorities) {
expect(p).toBe(MIN_PRIORITY);
}
});
});
describe('computeTierDrainOrder', () => {
it('ultra > pro > free priority ordering', async () => {
const { computeTierDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'free@x.com', tokenFile: 'free.json', tier: 'free' as const },
{ accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const },
{ accountId: 'pro@x.com', tokenFile: 'pro.json', tier: 'pro' as const },
];
const entries = computeTierDrainOrder(accounts);
const byId = new Map(
entries.map((e: { accountId: string; priority: number }) => [e.accountId, e.priority])
);
expect(byId.get('ultra@x.com')).toBeGreaterThan(byId.get('pro@x.com'));
expect(byId.get('pro@x.com')).toBeGreaterThan(byId.get('free@x.com'));
expect(byId.get('free@x.com')).toBeGreaterThanOrEqual(1);
});
it('unknown tier gets lowest priority (MIN_PRIORITY)', async () => {
const { computeTierDrainOrder, MIN_PRIORITY } = await loadDrainOrder();
const accounts = [
{ accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const },
{ accountId: 'unknown@x.com', tokenFile: 'unknown.json', tier: 'unknown' as const },
];
const entries = computeTierDrainOrder(accounts);
const unknownEntry = entries.find(
(e: { accountId: string }) => e.accountId === 'unknown@x.com'
);
expect(unknownEntry?.priority).toBe(MIN_PRIORITY);
});
it('accounts with same tier get equal priority', async () => {
const { computeTierDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'a@x.com', tokenFile: 'a.json', tier: 'pro' as const },
{ accountId: 'b@x.com', tokenFile: 'b.json', tier: 'pro' as const },
];
const entries = computeTierDrainOrder(accounts);
const [a, b] = entries as Array<{ accountId: string; priority: number }>;
expect(a.priority).toBe(b.priority);
});
it('tie-break by tokenFile (plain byte order, not locale) matches selector contract', async () => {
// The upstream Go selector breaks priority ties by Auth.ID asc (lowercased file path).
// For the agy prefix-named fleet, file names differ from emails, so verify we sort
// by tokenFile not accountId.
const { computeTierDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'z@x.com', tokenFile: 'antigravity-aaa.json', tier: 'pro' as const },
{ accountId: 'a@x.com', tokenFile: 'antigravity-zzz.json', tier: 'pro' as const },
];
// Both same tier -> same priority; sort by tokenFile should put 'aaa' before 'zzz'
const entries = computeTierDrainOrder(accounts) as Array<{
accountId: string;
tokenFile: string;
priority: number;
}>;
// Confirm equal priorities
expect(entries[0].priority).toBe(entries[1].priority);
// Sort as the display layer does: priority desc, tokenFile lowercase asc
const sorted = entries
.slice()
.sort(
(a, b) =>
b.priority - a.priority ||
(a.tokenFile.toLowerCase() < b.tokenFile.toLowerCase() ? -1 : 1)
);
// antigravity-aaa.json sorts before antigravity-zzz.json (byte/file order)
expect(sorted[0].tokenFile).toBe('antigravity-aaa.json');
expect(sorted[1].tokenFile).toBe('antigravity-zzz.json');
// accountId order is the reverse, confirming file order != email order for this fleet
expect(sorted[0].accountId).toBe('z@x.com');
expect(sorted[1].accountId).toBe('a@x.com');
});
it('tierDerived is true only for accounts with a real tier rank', async () => {
const { computeTierDrainOrder } = await loadDrainOrder();
const accounts = [
{ accountId: 'ultra@x.com', tokenFile: 'ultra.json', tier: 'ultra' as const },
{ accountId: 'unknown@x.com', tokenFile: 'unknown.json', tier: 'unknown' as const },
];
const entries = computeTierDrainOrder(accounts) as Array<{
accountId: string;
tierDerived: boolean;
}>;
const ultraEntry = entries.find((e) => e.accountId === 'ultra@x.com');
const unknownEntry = entries.find((e) => e.accountId === 'unknown@x.com');
expect(ultraEntry?.tierDerived).toBe(true);
expect(unknownEntry?.tierDerived).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Tie-break key (platform-conditional, mirrors upstream synthesizer)
// ---------------------------------------------------------------------------
describe('tieBreakKey', () => {
it('preserves case on non-Windows (byte order, upper before lower)', async () => {
const { tieBreakKey } = await loadOrderSubcommand();
// On non-Windows the selector compares raw Auth.ID bytes: 'B' (0x42) < 'a' (0x61).
const files = ['antigravity-aaa.json', 'antigravity-Bbb.json'];
const sorted = files
.slice()
.sort((a, b) => (tieBreakKey(a, 'linux') < tieBreakKey(b, 'linux') ? -1 : 1));
expect(sorted).toEqual(['antigravity-Bbb.json', 'antigravity-aaa.json']);
});
it('lowercases on Windows so mixed case sorts case-insensitively', async () => {
const { tieBreakKey } = await loadOrderSubcommand();
const files = ['antigravity-aaa.json', 'antigravity-Bbb.json'];
const sorted = files
.slice()
.sort((a, b) => (tieBreakKey(a, 'win32') < tieBreakKey(b, 'win32') ? -1 : 1));
expect(sorted).toEqual(['antigravity-aaa.json', 'antigravity-Bbb.json']);
});
});
// ---------------------------------------------------------------------------
// Direct file write tests
// ---------------------------------------------------------------------------
describe('writeAuthFilePriorityDirect', () => {
it('writes priority field to auth JSON', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json', { email: 'test@x.com' });
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
writeAuthFilePriorityDirect('test.json', 3);
const data = readAuthFile(authDir, 'test.json');
expect(data.priority).toBe(3);
});
});
it('preserves all other fields', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json', { email: 'test@x.com', token: 'abc123' });
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
writeAuthFilePriorityDirect('test.json', 2);
const data = readAuthFile(authDir, 'test.json');
expect(data.email).toBe('test@x.com');
expect(data.token).toBe('abc123');
expect(data.priority).toBe(2);
});
});
it('rejects priority 0 (management layer treats 0 as delete)', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json');
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
expect(() => writeAuthFilePriorityDirect('test.json', 0)).toThrow();
});
});
it('rejects negative priority', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json');
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
expect(() => writeAuthFilePriorityDirect('test.json', -1)).toThrow();
});
});
it('throws when file does not exist', async () => {
await withIsolatedHome(async (_homeDir) => {
const { writeAuthFilePriorityDirect } = await loadDrainOrder();
expect(() => writeAuthFilePriorityDirect('nonexistent.json', 1)).toThrow();
});
});
});
describe('readAuthFilePriority', () => {
it('returns the priority from a file that has one', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json', { priority: 5 });
const { readAuthFilePriority } = await loadDrainOrder();
expect(readAuthFilePriority('test.json')).toBe(5);
});
});
it('returns undefined for file with no priority', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json', { email: 'x@y.com' });
const { readAuthFilePriority } = await loadDrainOrder();
expect(readAuthFilePriority('test.json')).toBeUndefined();
});
});
it('returns undefined for missing file', async () => {
await withIsolatedHome(async (_homeDir) => {
const { readAuthFilePriority } = await loadDrainOrder();
expect(readAuthFilePriority('ghost.json')).toBeUndefined();
});
});
it('returns undefined for priority 0 (invalid sentinel)', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'test.json', { priority: 0 });
const { readAuthFilePriority } = await loadDrainOrder();
expect(readAuthFilePriority('test.json')).toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// applyDrainOrder (direct write path, proxy stopped)
// ---------------------------------------------------------------------------
describe('applyDrainOrder - direct write path (proxy stopped)', () => {
it('writes priorities to all files when proxy stopped', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com' });
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' });
const { applyDrainOrder } = await loadDrainOrder();
const entries = [
{
accountId: 'a@x.com',
tokenFile: 'a.json',
priority: 2,
tierDerived: false,
currentPriority: undefined,
},
{
accountId: 'b@x.com',
tokenFile: 'b.json',
priority: 1,
tierDerived: false,
currentPriority: undefined,
},
];
const result = await applyDrainOrder(entries, false);
expect(result.usedManagementApi).toBe(false);
expect(result.written).toHaveLength(2);
expect(result.failed).toHaveLength(0);
const a = readAuthFile(authDir, 'a.json');
const b = readAuthFile(authDir, 'b.json');
expect(a.priority).toBe(2);
expect(b.priority).toBe(1);
});
});
it('skips entries where priority already matches (idempotency)', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
const { applyDrainOrder } = await loadDrainOrder();
const entries = [
{
accountId: 'a@x.com',
tokenFile: 'a.json',
priority: 3,
tierDerived: false,
currentPriority: undefined,
},
];
const result = await applyDrainOrder(entries, false);
expect(result.skipped).toHaveLength(1);
expect(result.written).toHaveLength(0);
});
});
it('records failures for missing files', async () => {
await withIsolatedHome(async (_homeDir) => {
const { applyDrainOrder } = await loadDrainOrder();
const entries = [
{
accountId: 'ghost@x.com',
tokenFile: 'ghost.json',
priority: 2,
tierDerived: false,
currentPriority: undefined,
},
];
const result = await applyDrainOrder(entries, false);
expect(result.failed).toHaveLength(1);
expect(result.written).toHaveLength(0);
});
});
});
// ---------------------------------------------------------------------------
// Registry drain order persistence
// ---------------------------------------------------------------------------
describe('registry drain order persistence', () => {
it('saveDrainOrderConfig persists and loadDrainOrderConfig retrieves', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'antigravity-a.json', { email: 'a@x.com', type: 'antigravity' });
const { registerAccount } = await loadRegistry();
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
const { saveDrainOrderConfig, loadDrainOrderConfig } = await loadRegistry();
const config = { mode: 'manual' as const, orderedIds: ['a@x.com'] };
const saved = saveDrainOrderConfig('agy', config);
expect(saved).toBe(true);
const loaded = loadDrainOrderConfig('agy');
expect(loaded?.mode).toBe('manual');
expect(loaded?.orderedIds).toEqual(['a@x.com']);
});
});
it('clearDrainOrderConfig removes persisted 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' });
const { registerAccount } = await loadRegistry();
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
const { saveDrainOrderConfig, loadDrainOrderConfig, clearDrainOrderConfig } =
await loadRegistry();
saveDrainOrderConfig('agy', { mode: 'tier' });
const cleared = clearDrainOrderConfig('agy');
expect(cleared).toBe(true);
expect(loadDrainOrderConfig('agy')).toBeUndefined();
});
});
it('returns false for saveDrainOrderConfig when provider not in registry', async () => {
await withIsolatedHome(async (_homeDir) => {
const { saveDrainOrderConfig } = await loadRegistry();
// No accounts registered for 'gemini' -> returns false
const result = saveDrainOrderConfig('gemini', { mode: 'tier' });
expect(result).toBe(false);
});
});
});
// ---------------------------------------------------------------------------
// Attribution stability: auth_index ordering survives priority rewrite
// ---------------------------------------------------------------------------
describe('attribution stability - auth_index unaffected by priority rewrite', () => {
it('buildAuthIndexToAccountMap produces stable output regardless of priority values', async () => {
// Simulate the auth files list returned by /v0/management/auth-files with various priorities.
// auth_index is assigned by CLIProxy at load time based on file discovery order,
// NOT by priority. So priority rewrites must not change auth_index values.
const { buildAuthIndexToAccountMap } = await loadStatsTransformer();
const authFilesBeforeRewrite = [
{ auth_index: 0, email: 'a@x.com', provider: 'antigravity' },
{ auth_index: 1, email: 'b@x.com', provider: 'antigravity' },
{ auth_index: 2, email: 'c@x.com', provider: 'antigravity' },
];
const authFilesAfterRewrite = [
// Priority changed but auth_index values are assigned by CLIProxy independently
{ auth_index: 0, email: 'a@x.com', provider: 'antigravity', priority: 3 },
{ auth_index: 1, email: 'b@x.com', provider: 'antigravity', priority: 2 },
{ auth_index: 2, email: 'c@x.com', provider: 'antigravity', priority: 1 },
];
const mapBefore = buildAuthIndexToAccountMap(authFilesBeforeRewrite);
const mapAfter = buildAuthIndexToAccountMap(authFilesAfterRewrite);
// auth_index -> email mapping must be identical after priority rewrite
expect(mapBefore.get('0')).toBe('a@x.com');
expect(mapBefore.get('1')).toBe('b@x.com');
expect(mapBefore.get('2')).toBe('c@x.com');
expect(mapAfter.get('0')).toBe(mapBefore.get('0'));
expect(mapAfter.get('1')).toBe(mapBefore.get('1'));
expect(mapAfter.get('2')).toBe(mapBefore.get('2'));
});
it('buildAuthIndexToAccountMap skips entries without auth_index', async () => {
const { buildAuthIndexToAccountMap } = await loadStatsTransformer();
const authFiles = [
{ email: 'no-index@x.com', provider: 'antigravity' }, // no auth_index
{ auth_index: 5, email: 'has-index@x.com', provider: 'antigravity' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.size).toBe(1);
expect(map.get('5')).toBe('has-index@x.com');
expect(map.has('undefined')).toBe(false);
});
it('buildAuthIndexToAccountMap handles both numeric and string auth_index keys', async () => {
const { buildAuthIndexToAccountMap } = await loadStatsTransformer();
const authFiles = [
{ auth_index: 0, email: 'numeric@x.com', provider: 'antigravity' },
{ auth_index: '1', email: 'string@x.com', provider: 'antigravity' },
];
const map = buildAuthIndexToAccountMap(authFiles);
// Both stored as String(auth_index)
expect(map.get('0')).toBe('numeric@x.com');
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);
});
});
});
// ---------------------------------------------------------------------------
// clearAuthFilePriorityDirect + clearDrainOrderPriorities
// (the missing half of `order --reset`: actually strip residual priorities)
// ---------------------------------------------------------------------------
describe('clearAuthFilePriorityDirect', () => {
it('removes the priority field and preserves all other fields', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 4, keep: 'me' });
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
const removed = clearAuthFilePriorityDirect('a.json');
expect(removed).toBe(true);
const data = readAuthFile(authDir, 'a.json');
expect('priority' in data).toBe(false);
expect(data.keep).toBe('me');
expect(data.email).toBe('a@x.com');
});
});
it('returns false (no-op) when the file has no priority field', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com' });
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
expect(clearAuthFilePriorityDirect('a.json')).toBe(false);
});
});
it('throws when the file does not exist', async () => {
await withIsolatedHome(async () => {
const { clearAuthFilePriorityDirect } = await loadDrainOrder();
expect(() => clearAuthFilePriorityDirect('ghost.json')).toThrow();
});
});
});
describe('clearDrainOrderPriorities - direct path (proxy stopped)', () => {
it('clears residual priorities from all files via direct write', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], false);
expect(result.usedManagementApi).toBe(false);
expect(result.cleared).toEqual(['a.json']);
expect(result.alreadyClear).toEqual(['b.json']);
expect(result.failed).toHaveLength(0);
expect('priority' in readAuthFile(authDir, 'a.json')).toBe(false);
});
});
it('records a failure for a missing file', async () => {
await withIsolatedHome(async () => {
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['ghost.json'], false);
expect(result.failed).toHaveLength(1);
expect(result.cleared).toHaveLength(0);
});
});
});
describe('clearDrainOrderPriorities - management API path (proxy running)', () => {
it('PATCHes priority:0 (delete) for files with a residual priority and skips clear ones', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
writeAuthFile(authDir, 'b.json', { email: 'b@x.com' }); // already clear -> no API call
const calls: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
calls.push({ url, body: JSON.parse(String(init?.body ?? '{}')) });
return new Response('{}', { status: 200 });
}) as typeof fetch;
try {
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['a.json', 'b.json'], true);
expect(result.usedManagementApi).toBe(true);
expect(result.cleared).toEqual(['a.json']);
expect(result.alreadyClear).toEqual(['b.json']);
expect(result.failed).toHaveLength(0);
// Only a.json (which had a residual priority) triggered an API call,
// and it patched priority:0 (the delete sentinel).
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain('/v0/management/auth-files/fields');
expect(calls[0].body).toEqual({ name: 'a.json', priority: 0 });
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('records a failure when the PATCH returns a non-ok status', async () => {
await withIsolatedHome(async (homeDir) => {
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
writeAuthFile(authDir, 'a.json', { email: 'a@x.com', priority: 3 });
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
try {
const { clearDrainOrderPriorities } = await loadDrainOrder();
const result = await clearDrainOrderPriorities(['a.json'], true);
expect(result.cleared).toHaveLength(0);
expect(result.failed).toHaveLength(1);
expect(result.failed[0].tokenFile).toBe('a.json');
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
// ---------------------------------------------------------------------------
// fetchProxyAuthCooldowns - live in-proxy 429 cooldowns
// ---------------------------------------------------------------------------
describe('fetchProxyAuthCooldowns', () => {
it('classifies unavailable entries as cooldowns and parses next_retry_after', async () => {
await withIsolatedHome(async () => {
const next = '2026-06-11T12:00:00.000Z';
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
files: [
{ name: 'cooling.json', unavailable: true, next_retry_after: next },
{ name: 'no-retry.json', unavailable: true },
{ name: 'healthy.json', unavailable: false },
],
}),
{ status: 200 }
)) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
const cooldowns = await fetchProxyAuthCooldowns();
// Only the two unavailable entries are returned; healthy.json is excluded.
expect(cooldowns.map((c: { tokenFile: string }) => c.tokenFile).sort()).toEqual([
'cooling.json',
'no-retry.json',
]);
const cooling = cooldowns.find(
(c: { tokenFile: string }) => c.tokenFile === 'cooling.json'
);
expect(cooling?.cooldownUntil).toBe(Date.parse(next));
const noRetry = cooldowns.find(
(c: { tokenFile: string }) => c.tokenFile === 'no-retry.json'
);
expect(noRetry?.cooldownUntil).toBeUndefined();
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('degrades silently to [] on a non-ok response', async () => {
await withIsolatedHome(async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('err', { status: 404 })) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
expect(await fetchProxyAuthCooldowns()).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('degrades silently to [] on a network error', async () => {
await withIsolatedHome(async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError('network down');
}) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
expect(await fetchProxyAuthCooldowns()).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('degrades silently to [] on an unexpected response shape', async () => {
await withIsolatedHome(async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ unexpected: 'shape' }), { status: 200 })) as typeof fetch;
try {
const { fetchProxyAuthCooldowns } = await loadDrainOrder();
expect(await fetchProxyAuthCooldowns()).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
@@ -0,0 +1,423 @@
/**
* 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 });
}
}
const SETTINGS_ON: PoolRoutingSettings = {
poolEnabled: true,
strategy: 'fill-first',
sessionAffinityEnabled: true,
sessionAffinityTtl: '1h',
};
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);
});
});
});
describe('resolvePoolState - in-proxy cooldown classification', () => {
it('classifies an injected proxy cooldown as cooling with source "proxy"', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState } = await loadPoolState();
const until = 2_000_000;
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: until }],
now: 1_000_000,
});
expect(pool.states[0].state).toBe('cooling');
expect(pool.states[0].cooldownUntil).toBe(until);
expect(pool.states[0].cooldownSource).toBe('proxy');
});
});
it('treats a proxy cooldown with no next_retry_after as cooling (no reset time)', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json' }],
now: 1_000_000,
});
expect(pool.states[0].state).toBe('cooling');
expect(pool.states[0].cooldownUntil).toBeUndefined();
expect(pool.states[0].cooldownSource).toBe('proxy');
});
});
it('proxy cooldown wins over a persisted quota cooldown for the same account', 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: 1_500_000,
reason: 'quota_exhausted',
},
],
})
);
const { resolvePoolState } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 9_000_000 }],
now: 1_000_000,
});
// Proxy (live routing truth) wins: source 'proxy', its reset time, not the
// persisted quota-paused.json one.
expect(pool.states[0].cooldownSource).toBe('proxy');
expect(pool.states[0].cooldownUntil).toBe(9_000_000);
});
});
it('leaves accounts available when no proxy cooldown is provided (graceful degradation)', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
// proxyCooldowns omitted -> CCS-side view only.
now: 1_000_000,
});
expect(pool.states[0].state).toBe('available');
expect(hasProxySourcedCooling(pool)).toBe(false);
});
});
it('hasProxySourcedCooling reports true only when a proxy-sourced cooling exists', async () => {
await withIsolatedHome(async () => {
const { resolvePoolState, hasProxySourcedCooling } = await loadPoolState();
const pool = resolvePoolState({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [
account({ id: 'a@x.com', tokenFile: 'claude-a.json' }),
account({ id: 'b@x.com', tokenFile: 'claude-b.json' }),
],
proxyCooldowns: [{ tokenFile: 'claude-b.json', cooldownUntil: 2_000_000 }],
now: 1_000_000,
});
expect(hasProxySourcedCooling(pool)).toBe(true);
});
});
});
describe('resolvePoolStateWithProxyCooldowns - fetch + degradation', () => {
it('skips the proxy fetch and stays available when pool routing is OFF', async () => {
await withIsolatedHome(async () => {
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
let fetched = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response('{}', { status: 200 });
}) as typeof fetch;
try {
const pool = await resolvePoolStateWithProxyCooldowns({
provider: 'claude',
settings: SETTINGS_OFF,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
now: 1_000_000,
});
expect(pool.states[0].state).toBe('available');
// Pool OFF -> no management API call at all.
expect(fetched).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('honours injected proxyCooldowns without fetching, even when pool is ON', async () => {
await withIsolatedHome(async () => {
const { resolvePoolStateWithProxyCooldowns } = await loadPoolState();
let fetched = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response('{}', { status: 200 });
}) as typeof fetch;
try {
const pool = await resolvePoolStateWithProxyCooldowns({
provider: 'claude',
settings: SETTINGS_ON,
accounts: [account({ id: 'a@x.com', tokenFile: 'claude-a.json' })],
proxyCooldowns: [{ tokenFile: 'claude-a.json', cooldownUntil: 2_000_000 }],
now: 1_000_000,
});
expect(pool.states[0].cooldownSource).toBe('proxy');
// Injected cooldowns short-circuit the fetch.
expect(fetched).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
@@ -0,0 +1,164 @@
/**
* 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 every native Claude Code lane the user has:
* 1. The ambient ~/.claude login (via `claude auth status`).
* 2. Each isolated CCS account profile lane (per-profile CLAUDE_CONFIG_DIR
* instance dir). These are the exact multi-account population the guard
* exists to protect, so they MUST be inspected, not just the default lane.
*
* Lane emails are read directly from each lane's `.claude.json`
* (`oauthAccount.emailAddress`) — cheap file reads, no CLI spawn. The
* profiles.json v3.0 schema removed the email field, so the per-lane config is
* the source of truth for an account profile's logged-in email.
*
* 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. Missing or unreadable lane files are
* silently skipped — a corrupt profile must never block or crash auth.
*/
import * as fs from 'fs';
import * as path from 'path';
import { warn } from '../../utils/ui';
import { getClaudeAuthStatus } from '../../utils/claude-detector';
import { maskEmail } from './account-safety';
import InstanceManager from '../../management/instance-manager';
import { ProfileRegistry } from '../../auth/profile-registry';
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'];
/** A native Claude lane that overlaps with the new CLIProxy account email. */
interface LaneOverlap {
/** The lane's masked email (for display). */
maskedEmail: string;
/** Human label for the lane, e.g. "native Claude Code login" or `profile "work"`. */
laneLabel: string;
}
/**
* Read the OAuth email stored in a native Claude lane's `.claude.json`.
*
* Returns the lowercased/trimmed email, or null when the file is missing,
* unreadable, malformed, or has no logged-in account. Never throws — a corrupt
* profile is silently skipped (the guard is advisory).
*/
function readLaneEmail(claudeConfigDir: string): string | null {
try {
const claudeJsonPath = path.join(claudeConfigDir, '.claude.json');
const raw = fs.readFileSync(claudeJsonPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') return null;
const oauthAccount = (parsed as Record<string, unknown>).oauthAccount;
if (!oauthAccount || typeof oauthAccount !== 'object') return null;
const email = (oauthAccount as Record<string, unknown>).emailAddress;
if (typeof email !== 'string' || email.trim().length === 0) return null;
return email.toLowerCase().trim();
} catch {
// Missing / unreadable / malformed lane config — skip silently.
return null;
}
}
/**
* Enumerate CCS account profile lanes and collect any whose stored OAuth email
* matches the newly added CLIProxy account email.
*
* Pure file reads (one `.claude.json` per profile) — no CLI spawn. Best-effort:
* any failure to enumerate or read a lane is swallowed so the guard can never
* block or crash the add.
*/
function findAccountProfileOverlaps(normalizedEmail: string): LaneOverlap[] {
const overlaps: LaneOverlap[] = [];
try {
const registry = new ProfileRegistry();
const profiles = registry.getAllProfilesMerged();
const accountNames = Object.entries(profiles)
.filter(([, meta]) => meta.type === 'account')
.map(([name]) => name);
if (accountNames.length === 0) return overlaps;
const instanceMgr = new InstanceManager();
for (const name of accountNames) {
const instancePath = instanceMgr.getInstancePath(name);
const laneEmail = readLaneEmail(instancePath);
if (laneEmail && laneEmail === normalizedEmail) {
overlaps.push({ maskedEmail: maskEmail(laneEmail), laneLabel: `profile "${name}"` });
}
}
} catch {
// Registry / instance-manager construction failed — skip lane enumeration.
}
return overlaps;
}
/**
* Check whether the newly added CLIProxy account email matches the email of any
* native Claude Code lane: the ambient ~/.claude login or any isolated CCS
* account profile lane.
*
* Emits a warning to stderr for each overlapping lane. Silent on errors
* (CLI not found, not logged in, unreadable profile, etc.) — the check is
* best-effort and never blocks the add.
*
* @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 normalized = email.toLowerCase().trim();
if (normalized.length === 0) return;
const overlaps: LaneOverlap[] = [];
// 1. Ambient ~/.claude login (default lane).
const status = getClaudeAuthStatus();
if (status?.loggedIn && status.email) {
const ambientNormalized = status.email.toLowerCase().trim();
if (ambientNormalized === normalized) {
overlaps.push({
maskedEmail: maskEmail(status.email),
laneLabel: 'native Claude Code login',
});
}
}
// 2. Isolated CCS account profile lanes (per-profile CLAUDE_CONFIG_DIR).
overlaps.push(...findAccountProfileOverlaps(normalized));
if (overlaps.length === 0) return;
const masked = maskEmail(email);
console.error('');
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
console.error(` CLIProxy account: ${masked} (${provider})`);
for (const overlap of overlaps) {
console.error(` Native Claude Code lane: ${overlap.maskedEmail} (${overlap.laneLabel})`);
}
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.
}
}
+55 -6
View File
@@ -112,6 +112,37 @@ function loadQuotaPaused(): QuotaPausedFile {
return { entries: [] };
}
/**
* Read-only view of a persisted quota-cooldown pause.
* Exposed for visibility surfaces (e.g. `ccs cliproxy quota` pool section)
* that must distinguish a quota cooldown (with a reset time) from a manual pause.
*/
export interface QuotaCooldownEntry {
provider: CLIProxyProvider;
accountId: string;
/** ISO timestamp when the account was paused (matches AccountInfo.pausedAt) */
pausedAt: string;
/** Epoch ms when the cooldown is eligible to be lifted */
until: number;
reason: 'quota_exhausted';
}
/**
* Return the persisted quota-cooldown pauses recorded on disk.
*
* This is the cross-process source of truth for quota cooldowns: the in-memory
* cooldown map in quota-manager is process-local, but quota-paused.json is
* written by the long-lived proxy/monitor process and read by short-lived CLI
* invocations. Callers use it to label an account as cooling (vs manually
* paused) and to show the reset time.
*
* Entries are returned as-is (including expired ones); callers decide whether to
* treat `until <= now` as already cooled down.
*/
export function readQuotaCooldownEntries(): QuotaCooldownEntry[] {
return loadQuotaPaused().entries.map((entry) => ({ ...entry }));
}
function saveQuotaPaused(data: QuotaPausedFile): void {
const filePath = getQuotaPausedPath();
if (data.entries.length === 0) {
@@ -568,8 +599,9 @@ export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void {
saveAutoPaused(data);
}
// Error patterns that indicate Google has disabled/banned an account
const BAN_PATTERNS = [
// Error patterns that indicate a provider has disabled/banned an account.
// Shared patterns apply to all providers (Google and Anthropic OAuth flows).
const SHARED_BAN_PATTERNS = [
'disabled in this account',
'violation of terms of service',
'account has been disabled',
@@ -578,12 +610,28 @@ const BAN_PATTERNS = [
'account has been banned',
];
// Anthropic-specific disable patterns. Only applied when provider === 'claude'
// to avoid false-positive auto-pause on Google/Codex errors that may reference
// "policy" in rate-limit or scope messages.
const ANTHROPIC_BAN_PATTERNS = ['your account has been blocked', 'account is blocked'];
/**
* Check if an error message indicates an account ban/disable.
* Pass the provider so Anthropic-only patterns cannot trip Google providers.
*/
export function isBanResponse(errorMessage: string): boolean {
export function isBanResponse(errorMessage: string, provider?: CLIProxyProvider): boolean {
const lower = errorMessage.toLowerCase();
return BAN_PATTERNS.some((pattern) => lower.includes(pattern));
if (SHARED_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) return true;
if (provider === 'claude' && ANTHROPIC_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) {
return true;
}
return false;
}
/** Return the actor name (Google, Anthropic, etc.) for ban copy. */
function banActor(provider: CLIProxyProvider): string {
if (provider === 'claude') return 'Anthropic';
return 'Google';
}
/**
@@ -595,10 +643,11 @@ export function handleBanDetection(
accountId: string,
errorMessage: string
): boolean {
if (!isBanResponse(errorMessage)) return false;
if (!isBanResponse(errorMessage, provider)) return false;
const actor = banActor(provider);
console.error('');
console.error(warn('Account safety: account appears disabled by Google'));
console.error(warn(`Account safety: account appears disabled by ${actor}`));
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
console.error(` "${truncate(errorMessage, 120)}"`);
console.error('');
+714
View File
@@ -0,0 +1,714 @@
/**
* Drain Order Management
*
* Manages CLIProxy account drain order via top-level "priority" field in auth JSONs.
*
* Architecture:
* - Priority >= 1 always (management layer treats 0 as delete-the-attribute)
* - Write path is dual: management API when proxy running, direct file when stopped
* - Tier-aware defaults only where AccountTier metadata exists (agy/gemini)
* - Claude pools: tier is unknown -> stable file order + manual --set required
* - Selector drain order: priority bucket desc, then Auth.ID asc
*/
import * as fs from 'fs';
import * as path from 'path';
import { getAuthDir } from '../config/config-generator';
import {
getProxyTarget,
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;
/** Management API path for patching auth file fields */
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
/** Management API path for listing auth files (and their runtime status). */
const AUTH_FILES_PATH = '/v0/management/auth-files';
/** Timeout for management API calls in ms */
const MGMT_TIMEOUT_MS = 5000;
/**
* One in-proxy cooldown reading, keyed by auth file basename.
*
* Source of truth: GET /v0/management/auth-files. Verified field names from
* CLIProxyAPIPlus internal/api/handlers/management/auth_files.go buildAuthFileEntry:
* - "name" -> the token file basename
* - "unavailable" -> bool, set when a credential is cooling after a 429
* - "next_retry_after" -> RFC3339 timestamp, present only when non-zero
* CLIProxyAPI (the base upstream) exposes the same fields via its own
* auth-files handler (sdk/cliproxy/auth/types.go: Unavailable / NextRetryAfter).
*/
export interface ProxyAuthCooldown {
/** Token file basename (matches AccountInfo.tokenFile). */
tokenFile: string;
/** Epoch ms when the in-proxy cooldown is eligible to lift, if known. */
cooldownUntil?: number;
}
/**
* Tier rank for priority derivation.
* Higher rank = higher priority = drained first.
* ultra > pro > free, unknown = no rank (returns undefined).
*/
const TIER_RANK: Record<Exclude<AccountTier, 'unknown'>, number> = {
ultra: 3,
pro: 2,
free: 1,
};
/**
* Compute 1-based priority from a 0-based rank among N accounts.
* Rank 0 = highest priority = assigned priority N.
* Rank N-1 = lowest priority = assigned priority 1.
*
* Never returns 0 (management layer treats 0 as delete).
*/
export function rankToPriority(rank: number, total: number): number {
const raw = total - rank;
return Math.max(MIN_PRIORITY, raw);
}
/** Tier rank for a known tier, undefined for unknown. */
export function tierRank(tier: AccountTier | undefined): number | undefined {
if (!tier || tier === 'unknown') return undefined;
return TIER_RANK[tier];
}
/**
* Result of drain order computation for a single account.
*/
export interface DrainOrderEntry {
/** Account ID */
accountId: string;
/** Token file name (without directory) */
tokenFile: string;
/** Computed priority (>= 1, higher = drained earlier) */
priority: number;
/** Tier if known */
tier?: AccountTier;
/** Whether tier was used to derive this priority */
tierDerived: boolean;
/** Current priority read from auth file (undefined = not yet set) */
currentPriority: number | undefined;
}
/**
* Input record for priority computation.
*/
export interface DrainOrderInput {
accountId: string;
tokenFile: string;
tier?: AccountTier;
}
/**
* Compute drain order priorities for a list of accounts using manual ordering.
*
* @param orderedIds Account IDs in desired drain order (first = highest priority)
* @param allAccounts All accounts for the provider (some may not be in orderedIds)
* @returns DrainOrderEntry array ordered by resulting priority desc
*/
export function computeManualDrainOrder(
orderedIds: string[],
allAccounts: DrainOrderInput[]
): DrainOrderEntry[] {
const accountMap = new Map(allAccounts.map((a) => [a.accountId, a]));
const result: DrainOrderEntry[] = [];
// Reject duplicate IDs: a repeated account in --set is ambiguous and would
// otherwise be assigned two different priorities.
const seen = new Set<string>();
for (const id of orderedIds) {
if (seen.has(id)) {
throw new Error(`Duplicate account ID in order: ${id}`);
}
seen.add(id);
}
// Validate that all specified IDs exist
for (const id of orderedIds) {
if (!accountMap.has(id)) {
throw new Error(`Account ID not found: ${id}`);
}
}
const specifiedSet = new Set(orderedIds);
// Use total + 1 so rank (N-1) maps to priority 2, keeping every specified
// account strictly above the MIN_PRIORITY floor assigned to unspecified ones.
const total = orderedIds.length + 1;
// Assign priorities to specified accounts (first = highest priority)
for (let rank = 0; rank < orderedIds.length; rank++) {
const id = orderedIds[rank];
const account = accountMap.get(id);
if (!account) continue;
result.push({
accountId: id,
tokenFile: account.tokenFile,
priority: rankToPriority(rank, total),
tier: account.tier,
tierDerived: false,
currentPriority: undefined,
});
}
// Remaining accounts not in the specified list get priority 1 (lowest)
for (const account of allAccounts) {
if (!specifiedSet.has(account.accountId)) {
result.push({
accountId: account.accountId,
tokenFile: account.tokenFile,
priority: MIN_PRIORITY,
tier: account.tier,
tierDerived: false,
currentPriority: undefined,
});
}
}
return result;
}
/**
* Compute drain order priorities for accounts using tier metadata.
* Only valid where tier metadata exists (agy/gemini providers).
*
* Accounts with unknown tier are sorted last (priority MIN_PRIORITY).
* Accounts within the same tier bucket are given equal priorities.
*
* @param accounts Accounts with optional tier metadata
* @returns DrainOrderEntry array
*/
export function computeTierDrainOrder(accounts: DrainOrderInput[]): DrainOrderEntry[] {
// Group by tier rank
const withRank: Array<{ input: DrainOrderInput; rank: number }> = accounts.map((a) => ({
input: a,
rank: tierRank(a.tier) ?? 0,
}));
// Get unique ranks descending
const uniqueRanks = [...new Set(withRank.map((x) => x.rank))].sort((a, b) => b - a);
const numRanks = uniqueRanks.length;
// Assign priority buckets: highest rank -> highest priority (numRanks), lowest -> MIN_PRIORITY
const rankToPriorityMap = new Map<number, number>();
uniqueRanks.forEach((rank, idx) => {
// idx 0 = highest rank, gets highest priority
const priority = Math.max(MIN_PRIORITY, numRanks - idx);
rankToPriorityMap.set(rank, priority);
});
return withRank.map(({ input, rank }) => ({
accountId: input.accountId,
tokenFile: input.tokenFile,
priority: rankToPriorityMap.get(rank) ?? MIN_PRIORITY,
tier: input.tier,
tierDerived: rank > 0, // only true when tier contributed a real rank
currentPriority: undefined,
}));
}
/**
* Read the current priority from an auth JSON file.
* Returns undefined if file missing, unreadable, or has no priority field.
*/
export function readAuthFilePriority(tokenFile: string): number | undefined {
try {
const authDir = getAuthDir();
const filePath = path.join(authDir, tokenFile);
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { priority?: unknown };
if (typeof data.priority === 'number' && Number.isFinite(data.priority) && data.priority >= 1) {
return data.priority;
}
return undefined;
} catch {
return undefined;
}
}
/**
* Write priority directly to an auth JSON file (proxy must be stopped).
* Validates priority >= MIN_PRIORITY before writing.
* Preserves all other fields; uses atomic temp-rename.
*/
export function writeAuthFilePriorityDirect(tokenFile: string, priority: number): void {
if (priority < MIN_PRIORITY) {
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
}
const authDir = getAuthDir();
const filePath = path.join(authDir, tokenFile);
if (!fs.existsSync(filePath)) {
throw new Error(`Auth file not found: ${tokenFile}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as Record<string, unknown>;
data.priority = priority;
const tempPath = `${filePath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tempPath, filePath);
}
/**
* Write priority via CLIProxy management API (proxy must be running).
* Uses PATCH /v0/management/auth-files/fields.
*
* @param tokenFile File name (basename, e.g. "antigravity-foo.json")
* @param priority Priority value >= MIN_PRIORITY
* @returns true on success, false on failure
*/
export async function writeAuthFilePriorityViaApi(
tokenFile: string,
priority: number
): Promise<boolean> {
if (priority < MIN_PRIORITY) {
throw new Error(`Priority must be >= ${MIN_PRIORITY}, got ${priority}`);
}
return patchAuthFilePriority(tokenFile, priority);
}
/**
* Remove the priority attribute via CLIProxy management API (proxy running).
* Sends PATCH /v0/management/auth-files/fields with priority:0, which the
* management layer treats as "delete the attribute" (verified upstream:
* CLIProxyAPIPlus syncAuthFilePriorityAttribute deletes the attribute when the
* incoming priority is 0 or absent, which then persists the auth file without a
* top-level priority field).
*
* Using the API path is mandatory while the proxy is running: a direct file
* write would be clobbered by the proxy's whole-file MarkResult persist.
*
* @param tokenFile File name (basename, e.g. "antigravity-foo.json")
* @returns true on success, false on failure
*/
export async function clearAuthFilePriorityViaApi(tokenFile: string): Promise<boolean> {
return patchAuthFilePriority(tokenFile, 0);
}
/** Shared PATCH-fields call for setting (>=1) or clearing (0) a file's priority. */
async function patchAuthFilePriority(tokenFile: string, priority: number): Promise<boolean> {
const target = getProxyTarget();
const url = buildProxyUrl(target, AUTH_FILES_FIELDS_PATH);
const headers = buildManagementHeaders(target, { 'Content-Type': 'application/json' });
const body = JSON.stringify({ name: tokenFile, priority });
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: 'PATCH',
headers,
body,
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Remove the priority field directly from an auth JSON file (proxy stopped).
* No-op (returns false) when the file has no priority field. Preserves all
* other fields; uses the same atomic temp-rename as writeAuthFilePriorityDirect.
*
* @returns true if a priority field was present and removed, false otherwise
*/
export function clearAuthFilePriorityDirect(tokenFile: string): boolean {
const authDir = getAuthDir();
const filePath = path.join(authDir, tokenFile);
if (!fs.existsSync(filePath)) {
throw new Error(`Auth file not found: ${tokenFile}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as Record<string, unknown>;
if (!('priority' in data)) {
return false;
}
delete data.priority;
const tempPath = `${filePath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tempPath, filePath);
return true;
}
/** Result of clearing residual priorities across a set of auth files. */
export interface ClearDrainOrderPrioritiesResult {
/** Token files where a priority field was present and removed. */
cleared: string[];
/** Token files that had no priority field (nothing to do). */
alreadyClear: string[];
/** Token files that failed, with the reason. */
failed: Array<{ tokenFile: string; reason: string }>;
/** Whether the management API path was used (proxy was running). */
usedManagementApi: boolean;
}
/**
* Clear residual on-disk priority fields for the given auth files.
*
* Mirrors applyDrainOrder's dual write path:
* - Proxy running: PATCH priority:0 via the management API (the only safe path;
* a direct write is clobbered by the proxy's whole-file persist).
* - Proxy stopped: delete the field directly with an atomic temp-rename.
*
* Idempotent: files with no priority field are reported as alreadyClear, not
* failures. The running path always reports the file as cleared on a 200 (the
* API treats a missing-attribute delete as a successful no-op), so this is safe
* to run repeatedly.
*
* @param tokenFiles Auth file basenames to clear.
* @param proxyRunning Whether the proxy is currently running.
*/
export async function clearDrainOrderPriorities(
tokenFiles: string[],
proxyRunning: boolean
): Promise<ClearDrainOrderPrioritiesResult> {
const result: ClearDrainOrderPrioritiesResult = {
cleared: [],
alreadyClear: [],
failed: [],
usedManagementApi: proxyRunning,
};
for (const tokenFile of tokenFiles) {
if (proxyRunning) {
// Skip the API round-trip when the file already has no priority on disk.
if (readAuthFilePriority(tokenFile) === undefined) {
result.alreadyClear.push(tokenFile);
continue;
}
const ok = await clearAuthFilePriorityViaApi(tokenFile);
if (ok) {
result.cleared.push(tokenFile);
} else {
result.failed.push({ tokenFile, reason: 'management API PATCH failed' });
}
} else {
try {
const removed = clearAuthFilePriorityDirect(tokenFile);
if (removed) {
result.cleared.push(tokenFile);
} else {
result.alreadyClear.push(tokenFile);
}
} catch (err) {
result.failed.push({ tokenFile, reason: (err as Error).message });
}
}
}
return result;
}
/**
* Result of a drain order apply operation.
*/
export interface ApplyDrainOrderResult {
/** Entries that were written successfully */
written: DrainOrderEntry[];
/** Entries that were skipped (priority unchanged) */
skipped: DrainOrderEntry[];
/** Entries that failed to write */
failed: Array<{ entry: DrainOrderEntry; reason: string }>;
/** Whether the proxy was running during the write (management API path) */
usedManagementApi: boolean;
}
/**
* Apply drain order priorities to auth files.
*
* Write path selection:
* - Proxy running (HTTP health check passes): use management API
* - Proxy stopped: direct file write
*
* Idempotent: skips entries where priority already equals the target value.
*
* @param entries Drain order entries with target priorities
* @param proxyRunning Whether the proxy is currently running
* @returns Result summary
*/
export async function applyDrainOrder(
entries: DrainOrderEntry[],
proxyRunning: boolean
): Promise<ApplyDrainOrderResult> {
const result: ApplyDrainOrderResult = {
written: [],
skipped: [],
failed: [],
usedManagementApi: proxyRunning,
};
for (const entry of entries) {
// Idempotency: read current value and skip if already set
const currentPriority = readAuthFilePriority(entry.tokenFile);
if (currentPriority === entry.priority) {
result.skipped.push({ ...entry, currentPriority });
continue;
}
if (proxyRunning) {
const ok = await writeAuthFilePriorityViaApi(entry.tokenFile, entry.priority);
if (ok) {
result.written.push({ ...entry, currentPriority });
} else {
result.failed.push({
entry: { ...entry, currentPriority },
reason: 'management API PATCH failed',
});
}
} else {
try {
writeAuthFilePriorityDirect(entry.tokenFile, entry.priority);
result.written.push({ ...entry, currentPriority });
} catch (err) {
result.failed.push({
entry: { ...entry, currentPriority },
reason: (err as Error).message,
});
}
}
}
return result;
}
/**
* Read current drain order priorities for a set of accounts.
* Annotates each DrainOrderEntry with its current priority from disk.
*
* @param entries Entries to annotate (modifies currentPriority in-place)
*/
export function annotateCurrentPriorities(entries: DrainOrderEntry[]): void {
for (const entry of entries) {
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 };
}
/** Raw auth-file entry shape we parse out of the management listing. */
interface RawManagementAuthFile {
name?: unknown;
unavailable?: unknown;
next_retry_after?: unknown;
}
/**
* Fetch in-proxy cooldowns from the running CLIProxy.
*
* Pool routing turns cooling ON, so when a credential hits a 429 the proxy
* marks it Unavailable and rotates to a healthy one. That cooldown lives inside
* the proxy process (not in CCS's quota-paused.json), so the only way the quota
* Pool section can explain a session hop is to read it here.
*
* GET /v0/management/auth-files returns { files: [{ name, unavailable,
* next_retry_after, ... }] }. We classify any entry with unavailable=true as a
* cooling credential and parse next_retry_after (RFC3339) into epoch ms when
* present.
*
* Degrades silently to an empty array on any failure (proxy not running,
* endpoint missing on an older binary, malformed/unknown response shape,
* timeout): the caller falls back to its CCS-side cooldown view with no error
* spam in quota output. Latency is bounded by a single request and MGMT_TIMEOUT_MS.
*
* @returns One entry per auth file the proxy reports unavailable.
*/
export async function fetchProxyAuthCooldowns(): Promise<ProxyAuthCooldown[]> {
const target = getProxyTarget();
const url = buildProxyUrl(target, AUTH_FILES_PATH);
const headers = buildManagementHeaders(target);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MGMT_TIMEOUT_MS);
try {
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
if (!response.ok) {
return [];
}
const data = (await response.json()) as { files?: unknown };
if (!Array.isArray(data.files)) {
return [];
}
const cooldowns: ProxyAuthCooldown[] = [];
for (const raw of data.files as RawManagementAuthFile[]) {
// Defensive parse: only entries the proxy explicitly flags unavailable.
if (!raw || raw.unavailable !== true) {
continue;
}
const name = typeof raw.name === 'string' ? raw.name : undefined;
if (!name) {
continue;
}
let cooldownUntil: number | undefined;
if (typeof raw.next_retry_after === 'string') {
const parsed = Date.parse(raw.next_retry_after);
if (Number.isFinite(parsed)) {
cooldownUntil = parsed;
}
}
cooldowns.push({ tokenFile: name, cooldownUntil });
}
return cooldowns;
} catch {
return [];
} finally {
clearTimeout(timeoutId);
}
}
+316
View File
@@ -0,0 +1,316 @@
/**
* 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 in-proxy cooldown (read from GET /v0/management/auth-files) is the real
* hop mechanism when pool routing is ON: the proxy cools a credential on a 429
* and rotates. It wins over everything else because it is the live routing
* truth that actually caused the session to move.
* - 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,
fetchProxyAuthCooldowns,
type DrainOrderInput,
type ProxyAuthCooldown,
} from './drain-order';
import { getProviderAccounts } from './query';
import { readQuotaCooldownEntries } from './account-safety';
import { getCooldownUntil } from '../quota/quota-manager';
import { getProxyTarget } from '../proxy/proxy-target-resolver';
import { detectRunningProxy } from '../proxy/proxy-detector';
import { resolveLifecyclePort } from '../config/port-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.
* 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files),
* 'persisted' = quota-paused.json (cross-process),
* 'memory' = in-process quota-manager map.
*/
cooldownSource?: 'proxy' | '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;
/**
* Live in-proxy cooldowns (GET /v0/management/auth-files), keyed nowhere -
* passed as a flat list and indexed by tokenFile internally. When provided,
* an entry takes precedence over CCS-side cooldown views for the matching
* auth file. Defaults to none (CCS-side classification only).
*/
proxyCooldowns?: ProxyAuthCooldown[];
}
/**
* Classify a single account's pool state.
*
* Precedence:
* 1. A live in-proxy cooldown (proxy reports the credential unavailable after a
* 429) -> 'cooling' source 'proxy'. This is the actual hop mechanism when
* pool routing is ON, so it wins over the CCS-side views below.
* 2. A persisted quota cooldown whose pausedAt matches the account's pausedAt is
* a quota cooldown -> 'cooling' (cross-process truth wins).
* 3. A paused account without a matching cooldown record is a manual/safety pause
* -> 'paused'.
* 4. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
* 5. Otherwise -> 'available'.
*/
function classifyAccount(
provider: CLIProxyProvider,
account: AccountInfo,
cooldownByAccount: Map<string, { until: number; pausedAt: string }>,
proxyCooldownByTokenFile: Map<string, ProxyAuthCooldown>,
now: number
): PoolAccountState {
const base: PoolAccountState = {
accountId: account.id,
tokenFile: account.tokenFile,
tier: account.tier,
isDefault: account.isDefault,
state: 'available',
};
// Live in-proxy cooldown wins: this is the credential the proxy actually
// rotated out on a 429. A missing/zero next_retry_after still means cooling;
// we just cannot show a reset time for it.
const proxyCooldown = proxyCooldownByTokenFile.get(account.tokenFile);
if (
proxyCooldown &&
(proxyCooldown.cooldownUntil === undefined || proxyCooldown.cooldownUntil > now)
) {
return {
...base,
state: 'cooling',
cooldownUntil: proxyCooldown.cooldownUntil,
cooldownSource: 'proxy',
};
}
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.
*
* Synchronous: callers that want live in-proxy 429 cooldowns surfaced (the real
* hop mechanism when pool routing is ON) pass them in via input.proxyCooldowns,
* or use resolvePoolStateWithProxyCooldowns() which fetches them first.
*/
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 });
}
// Index live in-proxy cooldowns by token file (the proxy reports by file name).
const proxyCooldownByTokenFile = new Map<string, ProxyAuthCooldown>();
for (const entry of input.proxyCooldowns ?? []) {
proxyCooldownByTokenFile.set(entry.tokenFile, entry);
}
const states = accounts.map((account) =>
classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, 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 };
}
/**
* Whether any account was classified 'cooling' from the live in-proxy source.
* Used by the renderer to decide whether the "in-proxy cooldowns not shown"
* honesty note still applies.
*/
export function hasProxySourcedCooling(pool: PoolState): boolean {
return pool.states.some((s) => s.state === 'cooling' && s.cooldownSource === 'proxy');
}
/**
* Resolve pool state and, when pool routing is ON and the local proxy is
* reachable, fold in live in-proxy 429 cooldowns (the actual session-hop
* mechanism). Remote targets and a stopped/older proxy degrade silently to the
* synchronous CCS-side view with no error output.
*
* Kept separate from resolvePoolState() so the pure, synchronous resolver stays
* test-friendly and free of network/process detection.
*/
export async function resolvePoolStateWithProxyCooldowns(
input: ResolvePoolStateInput
): Promise<PoolState> {
// Only the local proxy exposes a management API we can read here; remote
// drain/cooldown management is out of scope (v1), so skip the fetch.
const shouldFetch = input.settings.poolEnabled && !getProxyTarget().isRemote;
let proxyCooldowns: ProxyAuthCooldown[] | undefined = input.proxyCooldowns;
if (proxyCooldowns === undefined && shouldFetch) {
try {
const proxyStatus = await detectRunningProxy(resolveLifecyclePort());
if (proxyStatus.running && proxyStatus.verified) {
proxyCooldowns = await fetchProxyAuthCooldowns();
}
} catch {
// Degrade silently: keep the CCS-side cooldown view.
proxyCooldowns = undefined;
}
}
return resolvePoolState({ ...input, proxyCooldowns });
}
+50 -1
View File
@@ -10,7 +10,7 @@ import { CLIProxyProvider } from '../types';
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
import { getAuthDir, getCliproxyDir } from '../config/config-generator';
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types';
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL, DrainOrderConfig } from './types';
import {
getAccountsRegistryPath,
getPausedDir,
@@ -269,6 +269,10 @@ function populateRegistryFromTokenFiles(
lastUsedAt:
hydratedAccount?.lastUsedAt ||
(token.stats.mtime || token.stats.birthtime || new Date()).toISOString(),
// Preserve tier from the hydrated registry entry. Without this, populate
// would silently strip tier metadata written by the quota command, breaking
// --by-tier ordering for file-copied fleets that are re-hydrated on read.
tier: hydratedAccount?.tier,
};
if (token.paused) {
@@ -820,3 +824,48 @@ export function discoverExistingAccounts(): void {
populateRegistryFromTokenFiles(registry);
});
}
/**
* Persist drain order configuration for a provider.
* The config survives re-auth and registry sync; priorities are NOT automatically
* re-applied after sync. Re-run `ccs cliproxy accounts order <provider> --by-tier`
* or `--set` after adding or re-authing accounts to re-apply.
*/
export function saveDrainOrderConfig(
provider: CLIProxyProvider,
config: DrainOrderConfig
): boolean {
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts) {
return false;
}
providerAccounts.drainOrder = config;
return true;
});
}
/**
* Load persisted drain order configuration for a provider.
* Returns undefined if none stored (implies file order).
*/
export function loadDrainOrderConfig(provider: CLIProxyProvider): DrainOrderConfig | undefined {
return withAccountsRegistryLock(() => {
const registry = readAccountsRegistryFromDisk();
return registry.providers[provider]?.drainOrder;
});
}
/**
* Clear drain order configuration for a provider (revert to file order).
*/
export function clearDrainOrderConfig(provider: CLIProxyProvider): boolean {
return mutateAccountsRegistry((registry) => {
const providerAccounts = registry.providers[provider];
if (!providerAccounts) {
return false;
}
delete providerAccounts.drainOrder;
return true;
});
}
+22
View File
@@ -41,12 +41,34 @@ export interface AccountInfo {
projectId?: string;
}
/**
* Drain order mode for a provider's account pool.
* - "manual": user-specified ordered list of account IDs
* - "tier": auto-derived from AccountTier (ultra > pro > free); unknown = last
* - "file": stable file-system order (default, no priority writes)
*/
export type DrainOrderMode = 'manual' | 'tier' | 'file';
/** Persisted drain order configuration for a provider */
export interface DrainOrderConfig {
/** How priorities were last set */
mode: DrainOrderMode;
/**
* Ordered account IDs for manual mode.
* First = drained first (highest priority).
* Stale IDs (accounts removed since last --set) are silently ignored on re-apply.
*/
orderedIds?: string[];
}
/** Provider accounts configuration */
export interface ProviderAccounts {
/** Default account ID for this provider */
default: string;
/** Map of account ID to account metadata */
accounts: Record<string, Omit<AccountInfo, 'id' | 'provider' | 'isDefault'>>;
/** Persisted drain order configuration (optional; absent = file order) */
drainOrder?: DrainOrderConfig;
}
/** Accounts registry structure */
+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',
+179
View File
@@ -0,0 +1,179 @@
/**
* Claude built-in provider shadow warning and first-run routing notice
*
* Shadow warning
* --------------
* The claude and anthropic provider names are reserved built-ins (priority 0.5).
* A user who has a settings profile or account profile named 'claude' or 'anthropic'
* will silently get the built-in rather than their own profile. This module emits
* a one-line, TTY-only, once-per-install warning with a rename hint in that case.
*
* First-run routing notice
* ------------------------
* On the very first launch of `ccs claude` a one-line notice is printed to stderr
* reminding the user that traffic routes through the local CLIProxy instance and
* that bare `ccs` still launches native Claude Code.
*
* Persistence: marker files inside ~/.ccs/cliproxy/ are used for both notices.
*/
import * as fs from 'fs';
import * as path from 'path';
import { warn, info } from '../utils/ui';
import {
getCcsDir,
loadOrCreateUnifiedConfig,
isUnifiedMode,
} from '../config/config-loader-facade';
import { readConfig } from '../utils/config-manager';
import { ProfileRegistry } from '../auth/profile-registry';
/** Marker file name inside ~/.ccs/cliproxy/ */
const SHADOW_WARNED_MARKER = '.claude-shadow-warned';
/** Names that the claude built-in shadows */
const SHADOWED_NAMES = new Set(['claude', 'anthropic']);
/** Get path to the once-per-install dismissal marker. */
function getShadowWarnedMarkerPath(): string {
return path.join(getCcsDir(), 'cliproxy', SHADOW_WARNED_MARKER);
}
/** Return true if this install has already shown the shadow warning. */
function shadowWarnAlreadyShown(): boolean {
try {
return fs.existsSync(getShadowWarnedMarkerPath());
} catch {
return true; // If we can't read, skip warning.
}
}
/** Persist the warning dismissal. */
function markShadowWarnShown(): void {
try {
const dir = path.join(getCcsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(getShadowWarnedMarkerPath(), new Date().toISOString(), {
encoding: 'utf8',
flag: 'w',
});
} catch {
// Best-effort — failure to persist is not fatal.
}
}
/**
* Check if the user has a settings or account profile named 'claude' or 'anthropic'
* that is being shadowed by the built-in provider.
*/
function detectShadowedProfile(): string | null {
try {
if (isUnifiedMode()) {
const unified = loadOrCreateUnifiedConfig();
const profileNames = Object.keys(unified.profiles || {});
const accountNames = Object.keys(unified.accounts || {});
const variantNames = Object.keys(unified.cliproxy?.variants || {});
for (const name of [...profileNames, ...accountNames, ...variantNames]) {
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
}
} else {
const config = readConfig();
const profileNames = Object.keys(config.profiles || {});
const cliproxyNames = Object.keys(config.cliproxy || {});
// Also check account-based profiles from profiles.json (mirror unified accounts check).
const registry = new ProfileRegistry();
const accountProfileNames = registry.listProfiles();
for (const name of [...profileNames, ...cliproxyNames, ...accountProfileNames]) {
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
}
}
} catch {
// If config is unreadable, skip the check silently.
}
return null;
}
/**
* Emit the shadow warning if conditions are met.
*
* Conditions:
* - Running in a TTY (no warning in pipes/CI)
* - Warning not already shown for this install
* - User has a profile named 'claude' or 'anthropic' that the built-in shadows
*
* Writes to stderr so it does not pollute stdout piped output.
*/
export function maybeWarnClaudeShadow(): void {
// TTY guard — no warning in non-interactive or CI environments
if (!process.stderr.isTTY) return;
if (shadowWarnAlreadyShown()) return;
const shadowedName = detectShadowedProfile();
if (!shadowedName) return;
markShadowWarnShown();
process.stderr.write('\n');
process.stderr.write(
warn(
`Profile '${shadowedName}' is shadowed by the built-in claude provider and cannot be reached via 'ccs ${shadowedName}'.`
) + '\n'
);
process.stderr.write(
` Rename it to continue using it: ccs config (or edit ~/.ccs/config.yaml / config.json)\n`
);
process.stderr.write('\n');
}
// ── First-run routing notice ──────────────────────────────────────────────────
/** Marker file for the once-per-install routing notice. */
const ROUTING_NOTICE_MARKER = '.claude-routing-noticed';
function getRoutingNoticeMarkerPath(): string {
return path.join(getCcsDir(), 'cliproxy', ROUTING_NOTICE_MARKER);
}
function routingNoticeAlreadyShown(): boolean {
try {
return fs.existsSync(getRoutingNoticeMarkerPath());
} catch {
return true;
}
}
function markRoutingNoticeShown(): void {
try {
const dir = path.join(getCcsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(getRoutingNoticeMarkerPath(), new Date().toISOString(), {
encoding: 'utf8',
flag: 'w',
});
} catch {
// Best-effort.
}
}
/**
* Print a one-time routing notice when the claude built-in provider first launches.
*
* Informs the user that traffic is routed through the local CLIProxy instance
* (not the Anthropic API directly) and that bare `ccs` still uses native Claude Code.
*
* TTY-only; written to stderr.
*/
export function maybeShowClaudeRoutingNotice(): void {
if (!process.stderr.isTTY) return;
if (routingNoticeAlreadyShown()) return;
markRoutingNoticeShown();
process.stderr.write(
info('ccs claude: traffic routes through the local CLIProxy instance.') + '\n'
);
process.stderr.write(
` Native Claude Code (direct Anthropic API) is still available via bare \`ccs\`.\n`
);
}
@@ -0,0 +1,587 @@
/**
* Gap 1: claude provider is model-neutral (no ANTHROPIC_MODEL pins in env output).
* Snapshot test proving:
* - getClaudeEnvVars('claude') emits no model env vars
* - getClaudeEnvVars('gemini') still emits model env vars (no spillover)
* - ensureProviderSettings does not write model pins for claude
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder';
import { clearConfigCache } from '../base-config-loader';
const MODEL_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
describe('claude provider model-neutral passthrough (Gap 1)', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-neutral-'));
process.env.CCS_HOME = tempHome;
clearConfigCache();
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
clearConfigCache();
});
it('emits no model env vars for the claude provider', () => {
const env = getClaudeEnvVars('claude');
for (const key of MODEL_KEYS) {
expect(env[key]).toBeUndefined();
}
});
it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
const env = getClaudeEnvVars('claude');
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
it('still emits model env vars for gemini provider (no spillover)', () => {
const env = getClaudeEnvVars('gemini');
for (const key of MODEL_KEYS) {
expect(typeof env[key]).toBe('string');
expect((env[key] as string).length).toBeGreaterThan(0);
}
});
it('ensureProviderSettings does not write model pins for claude', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
ensureProviderSettings('claude');
// Non-cursor providers use the legacy top-level path: ~/.ccs/claude.settings.json
const settingsPath = path.join(ccsDir, 'claude.settings.json');
expect(fs.existsSync(settingsPath)).toBe(true);
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
// Model keys must not be present (or must be undefined/empty)
const value = written.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
// Transport keys must be present
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
// ── Upgrade-path: existing claude.settings.json with stale default model pins ──
it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Simulate a user who ran `ccs claude` before the model-neutral change.
// These are the exact default values that were auto-written by older CCS.
const stalePins = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
...stalePins,
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// All stale pins must be removed
for (const key of MODEL_KEYS) {
const value = repaired.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
// Transport keys must still be present
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
it('preserves user-customised model pin that differs from the stale default', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
// User has customised ANTHROPIC_MODEL to a non-default value
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// Custom value must be preserved
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-7');
});
// ── One-shot migration guard (launch N+1) ─────────────────────────────────────
it('stale-pin migration runs at most once (marker prevents re-strip on launch N+1)', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Pre-place the migration marker so migration is considered done.
const markerDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
// Write a settings file that contains stale default pins.
const stalePins = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
};
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
...stalePins,
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// Migration already marked done — stale pins must NOT be stripped again.
// This proves a user re-pin that equals a stale default value survives on launch N+1.
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7');
});
it('settings file without ANTHROPIC_MODEL triggers no rewrite after migration marker set', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Pre-place the migration marker.
const markerDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const originalContent = JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
},
null,
2
);
fs.writeFileSync(settingsPath, originalContent + '\n', 'utf-8');
const statBefore = fs.statSync(settingsPath);
ensureProviderSettings('claude');
const statAfter = fs.statSync(settingsPath);
// mtime should not change — no rewrite triggered
expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// ANTHROPIC_MODEL must still be absent
expect(result.env.ANTHROPIC_MODEL).toBeUndefined();
});
// ── Historical-default set coverage ──────────────────────────────────────────
it('strips gen-2 pins (sonnet-4-5-20250929 / opus-4-5-20251101) on migration', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
const value = result.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
});
it('strips gen-3 mixed pin (opus-4-6) on migration', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-3-5-20241022',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
const value = result.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
});
it('preserves an explicit user pin to a value not in any stale-defaults set (e.g. claude-opus-4-8)', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// claude-opus-4-8 is not in any stale-defaults set; must survive migration
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
});
// ── Fresh-file migration marker and tier-pin survival ─────────────────────────
it('marks migration done on fresh-file creation so tier pins written by ccs claude --config survive', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
// Step 1: fresh ensureProviderSettings creates the file and the marker.
expect(fs.existsSync(settingsPath)).toBe(false);
ensureProviderSettings('claude');
expect(fs.existsSync(settingsPath)).toBe(true);
expect(fs.existsSync(markerPath)).toBe(true);
// Step 2: simulate 'ccs claude --config' pinning all four tier models.
const tierPins = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
};
const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
fs.writeFileSync(
settingsPath,
JSON.stringify({ ...existing, env: { ...existing.env, ...tierPins } }, null, 2) + '\n',
'utf-8'
);
// Step 3: second ensureProviderSettings must NOT strip the user-written pins
// because the migration marker already exists.
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
});
// ── Upgrader --config ordering: migrate-first, then re-pin survives ───────────
// Models the `ccs claude --config` flow on an UPGRADER: the executor runs
// ensureProviderSettings BEFORE configureProviderModel writes the user's pick.
// Step 1 (ensureProviderSettings) strips the old auto-pins and sets the marker;
// Step 2 (the user's --config write) lands a fresh pin; Step 3 (next plain
// launch) must NOT strip it because the marker is already set.
it('upgrader --config: ensureProviderSettings runs first, so a re-pin equal to a stale default survives the next launch', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
// Pre-existing upgrader file with OLD auto-written default pins, no marker.
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
}),
'utf-8'
);
expect(fs.existsSync(markerPath)).toBe(false);
// Step 1: executor runs ensureProviderSettings BEFORE the --config write.
// This strips the old auto-pins and sets the migration marker.
ensureProviderSettings('claude');
expect(fs.existsSync(markerPath)).toBe(true);
const afterMigrate = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
const value = afterMigrate.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
// Step 2: configureProviderModel writes the user's deliberate pick — and the
// pick happens to equal a current catalog default that is in the stale set.
const userPin = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
};
const current = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
fs.writeFileSync(
settingsPath,
JSON.stringify({ ...current, env: { ...current.env, ...userPin } }, null, 2) + '\n',
'utf-8'
);
// Step 3: next plain `ccs claude` launch must NOT strip the just-written pin
// because the marker was already set in Step 1.
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
});
// ── Remote read path: stale-pin filter without file mutation ──────────────────
it('getRemoteEnvVars (claude): drops stale default model pins from the settings file at read level', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const originalContent =
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
}) + '\n';
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const env = getRemoteEnvVars('claude', {
host: 'example.com',
port: 8317,
protocol: 'http',
});
// Stale defaults must NOT leak into the remote env.
for (const key of MODEL_KEYS) {
expect(env[key]).toBeUndefined();
}
// Transport keys are always set from the remote config.
expect(env.ANTHROPIC_BASE_URL).toContain('example.com');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
// Read-level only: the settings file on disk is untouched.
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent);
});
it('getRemoteEnvVars (claude): preserves a user-custom model pin not in any stale set', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default
},
}),
'utf-8'
);
const env = getRemoteEnvVars('claude', {
host: 'example.com',
port: 8317,
protocol: 'http',
});
// Custom pin survives the remote read path.
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
});
it('getRemoteEnvVars (claude): Priority 1 explicit custom settings path is NOT stale-filtered', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// An explicitly passed settings file is the user's deliberate choice and
// must be honored verbatim — even when it carries a value equal to a
// historical default.
const customPath = path.join(ccsDir, 'custom-claude.settings.json');
fs.writeFileSync(
customPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit
},
}),
'utf-8'
);
const env = getRemoteEnvVars(
'claude',
{ host: 'example.com', port: 8317, protocol: 'http' },
customPath
);
// Priority 1 untouched: the explicit pin survives.
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
it('getRemoteEnvVars (claude): migration marker set means pins are user-intentional and NOT filtered', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Marker present: the file was already cleaned once, so any pin that
// exists now was put there deliberately (e.g. ccs claude --config picked
// a value that happens to equal a historical default).
const markerDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit
},
}),
'utf-8'
);
const env = getRemoteEnvVars('claude', {
host: 'example.com',
port: 8317,
protocol: 'http',
});
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
});
+23 -16
View File
@@ -16,10 +16,10 @@ interface BaseSettings {
env: {
ANTHROPIC_BASE_URL: string;
ANTHROPIC_AUTH_TOKEN: string;
ANTHROPIC_MODEL: string;
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
ANTHROPIC_MODEL?: string;
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
};
}
@@ -66,16 +66,20 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
throw new Error('Missing or invalid "env" object');
}
const required = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
];
// claude provider is model-neutral: it does not pin model env vars so that
// the user's own Claude Code model selection is respected end-to-end.
if (provider !== 'claude') {
const required = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
];
for (const field of required) {
if (!settings.env[field as keyof BaseSettings['env']]) {
throw new Error(`Missing required field: env.${field}`);
for (const field of required) {
if (!settings.env[field as keyof BaseSettings['env']]) {
throw new Error(`Missing required field: env.${field}`);
}
}
}
@@ -90,14 +94,17 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
/**
* Get model mapping from base config
* Extracts model names from env vars
* Extracts model names from env vars.
* Returns undefined model fields for the claude provider (model-neutral passthrough).
*/
export function getModelMappingFromConfig(provider: CLIProxyProvider): ProviderModelMapping {
const config = loadBaseConfig(provider);
// claude is model-neutral: ANTHROPIC_MODEL is absent from its config; callers
// that need model IDs (e.g. getClaudeEnvVars) guard on provider !== 'claude'.
return {
defaultModel: config.env.ANTHROPIC_MODEL,
claudeModel: config.env.ANTHROPIC_MODEL,
defaultModel: config.env.ANTHROPIC_MODEL ?? '',
claudeModel: config.env.ANTHROPIC_MODEL ?? '',
opusModel: config.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
sonnetModel: config.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
haikuModel: config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
+175 -26
View File
@@ -31,7 +31,7 @@ import {
normalizeIFlowLegacyModelAliases,
normalizeModelIdForProvider,
} from '../ai-providers/model-id-normalizer';
import { getGlobalEnvConfig } from '../../config/config-loader-facade';
import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade';
/** Settings file structure for user overrides */
interface ProviderSettings {
@@ -52,6 +52,9 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
/** Minimum required env vars for the claude built-in provider (model-neutral). */
const REQUIRED_CLAUDE_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
@@ -200,28 +203,18 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
* Get environment variables for Claude CLI (bundled defaults)
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path.
*
* For the claude built-in provider the model env vars are intentionally omitted so that
* the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough).
*/
export function getClaudeEnvVars(
provider: CLIProxyProvider,
port: number = CLIPROXY_DEFAULT_PORT
): NodeJS.ProcessEnv {
const models = getModelMapping(provider);
// Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.)
const baseEnvVars = getEnvVarsFromConfig(provider);
// Core env vars that we always set dynamically
const coreEnvVars = {
// Provider-specific endpoint - routes to correct provider via URL path
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
// Filter out core env vars from base config to avoid conflicts
// Filter out model pins and URL/auth from base config (we set them dynamically)
const {
ANTHROPIC_BASE_URL: _baseUrl,
ANTHROPIC_AUTH_TOKEN: _authToken,
@@ -232,6 +225,22 @@ export function getClaudeEnvVars(
...additionalEnvVars
} = baseEnvVars;
// Core transport env vars set dynamically for all providers
const coreEnvVars: NodeJS.ProcessEnv = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
};
// Model pins: omitted for claude provider (model-neutral passthrough).
// For all other providers, set model vars from the base config model mapping.
if (provider !== 'claude') {
const models = getModelMapping(provider);
coreEnvVars.ANTHROPIC_MODEL = models.claudeModel;
coreEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opusModel || models.claudeModel;
coreEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnetModel || models.claudeModel;
coreEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haikuModel || models.claudeModel;
}
// Merge core env vars with additional env vars from base config
const mergedEnv = {
...coreEnvVars,
@@ -509,6 +518,98 @@ export function getEffectiveEnvVars(
return { ...globalEnv, ...getClaudeEnvVars(provider, port) };
}
/**
* All historically-shipped default model pins that CCS auto-wrote into
* claude.settings.json before the model-neutral passthrough change.
* A key is removed only when the stored value exactly matches one of the values
* in that key's set, so user-customised values are always preserved.
*/
const CLAUDE_STALE_MODEL_DEFAULTS: Record<string, Set<string>> = {
ANTHROPIC_MODEL: new Set([
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
]),
ANTHROPIC_DEFAULT_OPUS_MODEL: new Set([
'claude-opus-4-20250514',
'claude-opus-4-5-20251101',
'claude-opus-4-6',
'claude-opus-4-7',
]),
ANTHROPIC_DEFAULT_SONNET_MODEL: new Set([
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
]),
ANTHROPIC_DEFAULT_HAIKU_MODEL: new Set([
'claude-haiku-3-5-20241022',
'claude-haiku-4-5-20251001',
]),
};
/** Marker file that records when the one-time stale-pin migration has run. */
const CLAUDE_MODEL_MIGRATED_MARKER = '.claude-model-migrated';
/** Return true if the one-time stale-pin migration has already been applied. */
function claudeModelMigrationDone(): boolean {
try {
return fs.existsSync(path.join(getCcsDir(), 'cliproxy', CLAUDE_MODEL_MIGRATED_MARKER));
} catch {
return true; // Cannot read — treat as done to avoid repeated rewrites.
}
}
/** Record that the one-time stale-pin migration has been applied. */
function markClaudeModelMigrationDone(): void {
try {
const dir = path.join(getCcsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, CLAUDE_MODEL_MIGRATED_MARKER), new Date().toISOString(), {
encoding: 'utf8',
flag: 'w',
});
} catch {
// Best-effort — failure to persist is not fatal.
}
}
/**
* Remove stale model pins from an existing claude.settings.json env block.
* Only removes keys whose values appear in the set of historically-shipped
* defaults for that key, preserving user-customised values.
* Returns true when at least one key was removed (signals file needs rewriting).
*/
function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
let mutated = false;
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
if (staleValues.has(env[key])) {
delete env[key];
mutated = true;
}
}
return mutated;
}
/**
* Read-level equivalent of the stale-pin migration for paths that must not
* mutate the settings file (e.g. the remote env builder). Returns a copy of
* the env with any value equal to a historical default dropped per-key, while
* user-custom pins are preserved. Same per-key Sets as the local migration, so
* local / --config / remote read paths agree on which pins are stale.
*
* No marker is consulted or written: this is purely a read-time filter, so it
* is idempotent across launches without a one-shot guard.
*/
function filterClaudeStaleModelPins(env: Record<string, string>): Record<string, string> {
const result = { ...env };
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
if (staleValues.has(result[key])) {
delete result[key];
}
}
return result;
}
/**
* Copy bundled settings template to user directory if not exists
* Called during installation/first run
@@ -525,8 +626,13 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
};
// Create initial file when missing.
// A freshly created file has no stale pins by construction, so mark migration
// done immediately to prevent the one-time strip from running unnecessarily.
if (!fs.existsSync(settingsPath)) {
writeSettings({ env: defaultEnv });
if (provider === 'claude') {
markClaudeModelMigrationDone();
}
return;
}
@@ -564,11 +670,28 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
: {};
let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate));
for (const key of REQUIRED_PROVIDER_ENV_KEYS) {
// One-time migration: strip stale model pins written by older CCS versions into
// claude.settings.json. Guarded by a marker file so a user-re-pin that happens
// to equal a stale default value is not silently stripped on every subsequent launch.
if (provider === 'claude' && !claudeModelMigrationDone()) {
if (migrateClaudeStaleModelPins(mergedEnv)) {
mutated = true;
}
markClaudeModelMigrationDone();
}
// claude is model-neutral: only transport keys (URL + auth) are required; model pins are omitted.
const requiredKeys =
provider === 'claude' ? REQUIRED_CLAUDE_ENV_KEYS : REQUIRED_PROVIDER_ENV_KEYS;
for (const key of requiredKeys) {
const current = mergedEnv[key];
if (typeof current !== 'string' || current.trim().length === 0) {
mergedEnv[key] = defaultEnv[key] || '';
mutated = true;
const fallback = defaultEnv[key];
if (fallback) {
mergedEnv[key] = fallback;
mutated = true;
}
}
}
@@ -680,6 +803,21 @@ export function getRemoteEnvVars(
migrateDeprecatedModelNames(settingsPath, provider, settings);
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
// claude is model-neutral. The local launch path runs the one-time
// stale-pin migration (ensureProviderSettings), but the remote path
// never rewrites the file, so a remote-only user whose settings still
// carry an old auto-written default would stay pinned. Filter stale
// defaults at read level so values equal to a historical default are
// dropped while user-custom pins survive. Once the migration marker
// exists the file has already been cleaned, so any pin present after
// that is user-intentional (e.g. an explicit `ccs claude --config`
// pick that happens to equal a historical default) and must NOT be
// filtered. Priority 1 (explicit custom settings path) is
// intentionally left untouched: an explicitly passed settings file
// is the user's deliberate choice.
if (provider === 'claude' && !claudeModelMigrationDone()) {
userEnvVars = filterClaudeStaleModelPins(userEnvVars);
}
}
} catch {
// Invalid JSON - fall through to base config
@@ -689,7 +827,6 @@ export function getRemoteEnvVars(
// Priority 3: Base config defaults
if (Object.keys(userEnvVars).length === 0) {
const models = getModelMapping(provider);
const baseEnvVars = getEnvVarsFromConfig(provider);
// Filter out URL/auth from base config (we'll set those from remote config)
const {
@@ -697,13 +834,25 @@ export function getRemoteEnvVars(
ANTHROPIC_AUTH_TOKEN: _authToken,
...additionalEnvVars
} = baseEnvVars;
userEnvVars = {
...additionalEnvVars,
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
// claude is model-neutral: omit model pins so Claude Code's own /model
// selection is respected on remote launches too.
if (provider === 'claude') {
// Filter out undefined values coming from NodeJS.ProcessEnv spread.
userEnvVars = Object.fromEntries(
Object.entries(additionalEnvVars).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
);
} else {
const models = getModelMapping(provider);
userEnvVars = {
...additionalEnvVars,
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
}
}
// Build final env: global + user settings + remote URL/auth override
+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
@@ -484,4 +484,17 @@ describe('ensureModelConfiguration', () => {
await ensureModelConfiguration('codex', cfg, false);
expect(mockReconcileCodexModel).not.toHaveBeenCalled();
});
// claude is model-neutral passthrough — must never auto-prompt at launch
it('claude non-composite → configureProviderModel NOT called (model-neutral)', async () => {
const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig;
await ensureModelConfiguration('claude', cfg, false);
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
});
it('claude composite → configureProviderModel NOT called', async () => {
const cfg = { isComposite: true } as ExecutorConfig;
await ensureModelConfiguration('claude', cfg, false);
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
});
});
+5 -1
View File
@@ -369,13 +369,17 @@ export function runAccountSafetyGuards(
* Ensure provider model is configured on first run.
* Skipped for composite variants and remote proxy mode.
* Also reconciles Codex model for active plan.
*
* claude is model-neutral passthrough: the user's own /model selection inside
* Claude Code governs which model is used, so no auto-prompt at launch.
* Use `ccs claude --config` for an explicit pin opt-in.
*/
export async function ensureModelConfiguration(
provider: CLIProxyProvider,
cfg: ExecutorConfig,
verbose: boolean
): Promise<void> {
if (!cfg.isComposite && supportsModelConfig(provider)) {
if (!cfg.isComposite && provider !== 'claude' && supportsModelConfig(provider)) {
await configureProviderModel(provider, false, cfg.customSettingsPath);
}
+22
View File
@@ -67,6 +67,7 @@ import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolv
import { buildProxyChain } from './proxy-chain-builder';
import { warnBrokenModels } from './model-warnings';
import { launchClaude } from './claude-launcher';
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
/** Local alias so internal call sites need no change */
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
@@ -161,6 +162,11 @@ export async function execClaudeWithCLIProxy(
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
// claude built-in: warn once if a user profile is being shadowed
if (provider === 'claude') {
maybeWarnClaudeShadow();
}
// Variables for local proxy mode
let sessionId: string | undefined;
@@ -224,6 +230,17 @@ export async function execClaudeWithCLIProxy(
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
process.exit(1);
} else {
// Run the one-time stale-pin migration on the pre-existing settings file
// BEFORE writing the user's chosen pin. ensureProviderSettingsFile sets the
// migration marker, so the explicit --config pick survives the next launch
// even when it equals a historical default. Without this, the --config flow
// exits before the only ensureProviderSettingsFile call site (later in this
// function), so a later plain launch would strip the just-written pin.
// Skipped for custom-settings variants: those have no claude.settings
// migration and are written verbatim.
if (!cfg.customSettingsPath) {
ensureProviderSettingsFile(provider);
}
await configureProviderModel(provider, true, cfg.customSettingsPath);
process.exit(0);
}
@@ -279,6 +296,11 @@ export async function execClaudeWithCLIProxy(
// 5. Check for broken models (multi-tier for composite)
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
// 5a. claude built-in: one-time routing notice (first launch only)
if (provider === 'claude') {
maybeShowClaudeRoutingNotice();
}
// 6. Ensure user settings file exists
ensureProviderSettingsFile(provider);
+20
View File
@@ -262,6 +262,26 @@ export function isOnCooldown(provider: CLIProxyProvider, accountId: string): boo
return true;
}
/**
* Get the epoch-ms timestamp when an account's in-memory cooldown expires.
* Returns undefined when the account is not on cooldown (or the cooldown has
* already lapsed). This is the process-local cooldown; for the cross-process
* source of truth see readQuotaCooldownEntries() in account-safety.
*/
export function getCooldownUntil(
provider: CLIProxyProvider,
accountId: string
): number | undefined {
const key = getCacheKey(provider, accountId);
const entry = cooldownMap.get(key);
if (!entry) return undefined;
if (Date.now() > entry.until) {
cooldownMap.delete(key);
return undefined;
}
return entry.until;
}
/**
* Apply cooldown to an exhausted account
*/
@@ -285,4 +285,84 @@ describe('cliproxy routing strategy service', () => {
expect(state.message).toContain('not reachable');
});
});
// PR #1514 fix index 2: for a remote target, readCliproxyRoutingState must NOT
// present the local pool flag as if it described the remote proxy. It surfaces
// manageable:false + a message, mirroring the session-affinity remote handling.
it('marks remote pool routing as not manageable (local flag does not describe the remote proxy)', async () => {
await withScopedConfig(async () => {
routingTarget = {
host: 'remote.example.com',
port: 8080,
protocol: 'http',
isRemote: true,
};
responseFactory = async () =>
new Response(JSON.stringify({ strategy: 'round-robin' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// Enable the LOCAL pool flag — the remote proxy must still report not-manageable.
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
}
});
const mod = await loadRoutingModule();
const state = await mod.readCliproxyRoutingState();
expect(state.target).toBe('remote');
expect(state.poolRouting?.manageable).toBe(false);
expect(state.poolRouting?.message).toContain('remote proxy');
});
});
// PR #1514 fix index 14 (backend): when local pool routing is enabled, the apply
// result message must carry the pool-override note so API/dashboard consumers see
// the same caveat the CLI prints.
it('appends a pool-active override note to local strategy apply when pool routing is on', async () => {
await withScopedConfig(async () => {
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
}
});
const mod = await loadRoutingModule();
const result = await mod.applyCliproxyRoutingStrategy('round-robin');
expect(result.message).toContain('Pool routing is active');
expect(result.message).toContain('ccs cliproxy pool --disable');
});
});
it('appends a pool-active override note to local affinity apply when pool routing is on', async () => {
await withScopedConfig(async () => {
const { mutateUnifiedConfig } = await import('../../../config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
}
});
const mod = await loadRoutingModule();
const result = await mod.applyCliproxySessionAffinitySettings({ enabled: true, ttl: '1h' });
expect(result.message).toContain('Pool routing is active');
expect(result.message).toContain('ccs cliproxy pool --disable');
});
});
// Without pool routing, the apply message must NOT carry the override note.
it('does not append the pool-override note when pool routing is off', async () => {
await withScopedConfig(async () => {
const mod = await loadRoutingModule();
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
expect(result.message).not.toContain('Pool routing is active');
});
});
});
@@ -0,0 +1,190 @@
/**
* Pool onboarding hint
*
* Fires once (per install, TTY only) when a user has >= 2 native Claude account
* profiles and has not yet enabled pool routing. Covers the #1464 use case:
* existing users with multiple native profiles who have never touched CLIProxy
* and would miss the 1->2 account-add opt-in prompt.
*
* Three hint sites call this module:
* 1. ccs doctor (post-checks summary, fires for ALL install types)
* 2. account-flow launch (print-only, pre-spawn) - unified config only
* 3. ccs auth create (before spawning Claude for a 2nd+ profile) - unified config only
*
* Sites 2 and 3 are gated on hasUnifiedConfig() so that legacy profiles.json-only
* installs receive the hint exclusively from ccs doctor (site 1). This ensures
* once-per-install dismissal semantics for every install type: unified installs
* dismiss via config.yaml on first show; legacy installs see the hint from
* doctor only (which is the natural discovery surface for that population).
*
* Dismissal: stored in pool_routing.onboarding_hint_dismissed in config.yaml.
* Reuses the Phase 3 pool_routing schema family - one schema home, no duplicate
* plumbing. Legacy installs cannot persist a dismissal without config.yaml;
* this is intentional - dismissal for legacy users happens via ccs migrate or
* pool enable (both create config.yaml).
*
* TTY contract: non-TTY sessions never print the hint (single line only).
* The hint is informational only - it never blocks the calling flow.
*
* History note: pooled sessions live in the CLIProxy lane, not the native
* profile's CLAUDE_CONFIG_DIR lane. Transcripts and --continue inventory do
* NOT follow. This is stated on the docs page linked in the hint copy.
*/
import { info } from '../../utils/ui';
import {
loadOrCreateUnifiedConfig,
mutateConfig,
hasUnifiedConfig,
} from '../../config/config-loader-facade';
import type { UnifiedConfig } from '../../config/unified-config-types';
import { ProfileRegistry } from '../../auth/profile-registry';
// Docs anchor - Phase 7 acceptance item: verify curl returns HTTP 200 before release.
// Pattern matches canonical docs domain used in error-codes.ts and version-command.ts.
export const POOL_DOCS_LINK = 'https://docs.ccs.kaitran.ca/features/pool';
/**
* Whether the one-time pool onboarding hint has already been dismissed.
*
* @param config - Optional pre-loaded config to avoid a redundant disk read +
* YAML parse. When omitted the config is loaded here.
*/
export function isOnboardingHintDismissed(config?: UnifiedConfig): boolean {
const cfg = config ?? loadOrCreateUnifiedConfig();
return cfg.cliproxy?.pool_routing?.onboarding_hint_dismissed === true;
}
/**
* Mark the pool onboarding hint as permanently dismissed.
* Written after first display so subsequent invocations are silent.
*
* Guard: skipped when config.yaml does not yet exist (legacy profiles.json-only
* installs). Writing here would create config.yaml, flipping isUnifiedMode()
* and silently mode-migrating the install outside the deliberate ccs migrate
* flow. For those users the hint may re-appear across process restarts; that
* is acceptable -- the hint is informational and non-blocking, and the user
* can silence it permanently via ccs migrate.
*/
export function dismissOnboardingHint(): void {
if (!hasUnifiedConfig()) {
// Legacy-only install: skip persist to avoid implicit config.yaml creation.
return;
}
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
onboarding_hint_dismissed: true,
};
});
}
/**
* Whether pool routing is currently enabled.
*
* @param config - Optional pre-loaded config to avoid a redundant disk read +
* YAML parse. When omitted the config is loaded here.
*/
function isPoolEnabled(config?: UnifiedConfig): boolean {
const cfg = config ?? loadOrCreateUnifiedConfig();
return cfg.cliproxy?.pool_routing?.enabled === true;
}
/**
* Count native Claude account profiles (type === 'account').
*
* Uses ProfileRegistry so the count respects both legacy profiles.json and
* unified config accounts - same source of truth as the rest of the codebase.
*
* type === 'account' implies Claude: per the profile-registry schema, every
* account profile is an isolated Claude instance (CLAUDE_CONFIG_DIR lane) with
* no CLI discriminator. Gemini/Codex multi-account lives in CLIProxy auth
* files, never in profiles.json, so this filter cannot over-count.
*/
export function countNativeClaudeProfiles(): number {
try {
const registry = new ProfileRegistry();
const profiles = registry.getAllProfilesMerged();
return Object.values(profiles).filter((p) => p.type === 'account').length;
} catch {
return 0;
}
}
export interface OnboardingHintResult {
/** Whether the hint was printed */
printed: boolean;
/** Why the hint was skipped (only set when printed === false) */
skipReason?: string;
}
/**
* Maybe print the one-time pool onboarding hint.
*
* Skips silently when any of the following are true:
* - Not a TTY (piped / CI / non-interactive)
* - Fewer than 2 native Claude account profiles exist
* - Pool routing is already enabled
* - The hint was already dismissed
* - Any error occurs while deciding (e.g. malformed config.yaml)
*
* Checks run cheapest-first: TTY and profile count are evaluated before any
* config read, and the config is loaded exactly once for the two
* config-derived checks. The entire decision is wrapped in try/catch so a
* hint failure can never break an account launch.
*
* When the hint is printed for the first time it is also dismissed so it
* never appears again (it is informational, not an interactive prompt).
*
* @param profileCount - Pre-computed profile count (pass undefined to compute
* it here). The caller may pass a count it already has to avoid a second
* registry read.
*/
export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHintResult {
// A hint must never break a launch. Any error in the decision path
// (malformed config.yaml, registry read failure, etc.) silently skips the
// hint rather than rethrowing into the caller's account-launch flow.
try {
// Cheapest checks first, no disk read required.
// Non-TTY: stay silent (piped / CI / non-interactive).
if (!process.stdout.isTTY) {
return { printed: false, skipReason: 'non-tty' };
}
const count = profileCount ?? countNativeClaudeProfiles();
if (count < 2) {
return { printed: false, skipReason: 'fewer-than-2-profiles' };
}
// Load config once and reuse it for both config-derived checks below,
// avoiding two disk reads + YAML parses per call.
const config = loadOrCreateUnifiedConfig();
if (isPoolEnabled(config)) {
return { printed: false, skipReason: 'pool-already-enabled' };
}
if (isOnboardingHintDismissed(config)) {
return { printed: false, skipReason: 'dismissed' };
}
console.log(
info(
`You have ${count} Claude profiles. Pool routing can auto-continue 'ccs claude' when one account hits its limit. Enable: ccs cliproxy pool --enable Docs: ${POOL_DOCS_LINK}`
)
);
// Dismiss so subsequent invocations (other sites or next run) are silent.
try {
dismissOnboardingHint();
} catch {
// Best-effort: a config write failure must not surface to the user here.
}
return { printed: true };
} catch {
// Decision failed (e.g. malformed config) - skip the hint, never throw.
return { printed: false, skipReason: 'error' };
}
}
+280
View File
@@ -0,0 +1,280 @@
/**
* 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,
hasUnifiedConfig,
} 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 {
// Defense in depth: never write the unified config on a legacy install — that
// would create config.yaml and silently flip isUnifiedMode(). maybeOfferPoolRouting
// already gates the only caller, but this keeps the exported helper safe too.
if (!hasUnifiedConfig()) return;
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' };
}
// Legacy install guard: on a profiles.json/config.json-only install there is no
// unified config.yaml. Both accept (enablePoolRouting) and decline
// (dismissPoolPrompt) write the unified config, which would create config.yaml
// and silently flip isUnifiedMode() outside the deliberate `ccs migrate` flow,
// orphaning the user's config.json profiles. Skip entirely before any prompt
// or dismissal persistence. Mirrors the guards in create-command.ts and
// account-flow.ts and the hazard documented in pool-onboarding-hint.ts.
// Silent by design: legacy installs receive pool guidance exclusively from
// ccs doctor (pool-onboarding-hint site 1); printing here would nag on every
// account add with no dismissal available.
if (!hasUnifiedConfig()) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'legacy-config' };
}
// 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.
// Check before account count so remote targets with locally-unknown registries
// still get the hint (the remote CLIProxy holds the authoritative account list).
const target = getProxyTarget();
if (target.isRemote) {
printRemoteHint(provider);
return { prompted: false, enabled: false, skipped: true, skipReason: 'remote-target' };
}
// 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 (before TTY) ensures
// re-auth dedup always returns not-at-transition even in non-TTY/CI sessions.
const accountCountAfter = getProviderAccounts(provider).length;
if (accountCountAfter < 2) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'not-at-transition' };
}
// Non-TTY (piped input / CI): skip silently
if (!process.stdin.isTTY || !process.stderr.isTTY) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'non-tty' };
}
// Automation bypass: InteractivePrompt.confirm auto-returns true when --yes/-y
// is in argv or CCS_YES=1 is set, regardless of the default:false. This is an
// INSTANCE-GLOBAL routing/cooling consent decision; a generic automation flag
// must never grant it. Skip WITHOUT printing the prompt and WITHOUT persisting
// dismissal, so a future interactive run still offers the opt-in.
if (
process.env.CCS_YES === '1' ||
process.argv.includes('--yes') ||
process.argv.includes('-y')
) {
return { prompted: false, enabled: false, skipped: true, skipReason: 'automation-bypass' };
}
// 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.failed) {
// Config regeneration failed and the flag was rolled back: pool routing is
// NOT active. Surface the recovery copy and report enabled:false so callers
// (and quota/dashboard) do not claim pool ON.
console.log(warn(result.message));
console.log('');
return { prompted: true, enabled: false, skipped: false };
}
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 };
}
+390 -5
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,20 +9,356 @@ 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';
/**
* Pool-active override warning text. When pool routing is enabled the generator
* forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so
* an apply via API or dashboard will not take effect. The CLI prints this same
* text before applying; appending it to the apply result message lets dashboard
* and API consumers surface the same caveat (the CLI warns, the dashboard did not).
*/
function poolActiveOverrideNote(kind: 'strategy' | 'affinity'): string {
const what = kind === 'strategy' ? 'stored strategy' : 'stored affinity setting';
return (
`[!] Pool routing is active. The ${what} will not take effect\n` +
` until pool routing is disabled: ccs cliproxy pool --disable`
);
}
/** Whether pool routing is enabled in the local unified config. */
function isLocalPoolRoutingEnabled(): boolean {
return loadOrCreateUnifiedConfig().cliproxy?.pool_routing?.enabled === true;
}
export interface EnablePoolRoutingResult {
/** Whether the pool routing state actually changed */
changed: boolean;
/** Whether an existing explicit user routing setting was preserved */
preservedExplicitSetting: boolean;
/**
* True when the config could not be regenerated and the pool flag was rolled
* back. Pool routing is NOT active; the message carries recovery guidance.
*/
failed?: boolean;
message: string;
}
export interface DisablePoolRoutingResult {
changed: boolean;
/** True when the config could not be regenerated and the flag was rolled back. */
failed?: 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;
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
// Already-enabled repair path: the flag may have been persisted by a prior call
// whose regenerateConfig threw (leaving config.yaml non-pool while the flag says
// enabled). Re-run regenerateConfig so `pool --enable` is an idempotent repair
// command rather than a no-op that can never fix a half-applied state.
if (already) {
try {
regenerateConfig(port, { configPath, authDir });
} catch (err) {
return {
changed: false,
preservedExplicitSetting: false,
failed: true,
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged enabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --enable`,
};
}
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,
};
});
// The flag is persisted before regenerateConfig. If regeneration throws, roll
// the flag back so status surfaces (pool --enable, quota, dashboard) do not
// report pool ON while config.yaml still runs non-pool routing.
try {
regenerateConfig(port, { configPath, authDir });
} catch (err) {
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
enabled: false,
};
});
return {
changed: false,
preservedExplicitSetting,
failed: true,
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing was not enabled; fix the file permission and re-run: ccs cliproxy pool --enable`,
};
}
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;
const configPath = options.configPath ?? getConfigPathForPort(port);
const authDir = options.authDir ?? getAuthDir();
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,
};
});
// The flag is already cleared (matching user intent). If regeneration throws we
// keep enabled:false rather than rolling back to true — re-asserting pool ON
// would reintroduce the single-account blackout the disable path exists to
// prevent. Surface the failure so the user can fix permissions and re-run.
try {
regenerateConfig(port, { configPath, authDir });
} catch (err) {
return {
changed: true,
failed: true,
message: `[X] Could not write CLIProxy config: ${(err as Error).message}.\n Pool routing is flagged disabled but the config was not regenerated.\n Fix the file permission and re-run: ccs cliproxy pool --disable`,
};
}
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}+$`);
/**
* 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;
/**
* Whether CCS can manage pool routing for this target. enablePoolRouting only
* writes LOCAL config files, so a remote proxy is never affected by the local
* pool flag. For remote targets this is false and `enabled` reflects the local
* config only (not the remote proxy's behaviour). Omitted (treated as true)
* for local targets. Mirrors CliproxySessionAffinityState.manageable.
*/
manageable?: boolean;
/** Explanation surfaced when manageable is false (remote target). */
message?: string;
}
export interface CliproxyRoutingState {
strategy: CliproxyRoutingStrategy;
source: 'live' | 'config';
target: 'local' | 'remote';
reachable: boolean;
message?: string;
/**
* Pool routing mode. For local targets `enabled` reflects the active proxy
* config. For remote targets `manageable` is false and `enabled` reflects only
* the local config (the remote proxy is not affected by it).
*/
poolRouting?: CliproxyPoolRoutingState;
}
export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
@@ -142,15 +480,48 @@ 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) {
// Do NOT attach the local pool flag as if it described the remote proxy.
// enablePoolRouting only writes local config files; the remote proxy keeps
// its own routing/cooling. Surface manageable:false + a message so the
// dashboard renders "local only / not applied" instead of claiming the
// remote proxy is pool-managed. Mirrors readCliproxySessionAffinityState.
return {
strategy: await fetchLiveCliproxyRoutingStrategy(),
source: 'live',
target: 'remote',
reachable: true,
poolRouting: {
enabled: poolRouting.enabled,
maxRetryCredentials: poolRouting.maxRetryCredentials,
manageable: false,
message:
'Pool routing is managed from the local config only and does not affect this remote proxy. ' +
'Configure cooling and routing on the host running CLIProxy instead.',
},
};
}
@@ -160,6 +531,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
source: 'live',
target: 'local',
reachable: true,
poolRouting,
};
} catch {
return {
@@ -168,6 +540,7 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
target: 'local',
reachable: false,
message: 'Local CLIProxy is not reachable. Showing the saved startup default.',
poolRouting,
};
}
}
@@ -223,6 +596,11 @@ export async function applyCliproxyRoutingStrategy(
};
}
// Pool routing overrides the stored strategy at config-generation time, so the
// apply will not take effect until pool routing is disabled. Append the same
// note the CLI prints so dashboard/API consumers see the override too.
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('strategy')}` : '';
mutateConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
@@ -238,7 +616,7 @@ export async function applyCliproxyRoutingStrategy(
target: 'local',
reachable: true,
applied: 'live-and-config',
message: 'Updated the running proxy and saved the local startup default.',
message: 'Updated the running proxy and saved the local startup default.' + poolNote,
};
} catch {
return {
@@ -247,7 +625,8 @@ export async function applyCliproxyRoutingStrategy(
target: 'local',
reachable: false,
applied: 'config-only',
message: 'Saved the local startup default. It will apply the next time CLIProxy starts.',
message:
'Saved the local startup default. It will apply the next time CLIProxy starts.' + poolNote,
};
}
}
@@ -278,6 +657,10 @@ export async function applyCliproxySessionAffinitySettings(
current.ttl ??
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
// Pool routing overrides the stored session-affinity at config-generation time;
// append the same override note the CLI prints so dashboard/API consumers see it.
const poolNote = isLocalPoolRoutingEnabled() ? `\n\n${poolActiveOverrideNote('affinity')}` : '';
mutateConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = {
@@ -298,9 +681,11 @@ export async function applyCliproxySessionAffinitySettings(
reachable,
manageable: true,
applied: 'config-only',
message: reachable
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.',
message:
(reachable
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.') +
poolNote,
};
}
@@ -0,0 +1,159 @@
/**
* Tests for handleOrderSubcommand presentation + reset behavior.
*
* Why these matter:
* - File-mode SHOW must render from the shared resolver (selector pick order +
* drift), not an alphabetical re-sort. Otherwise the displayed order is the
* inverse of what CLIProxy actually drains whenever residual on-disk
* priorities exist (e.g. left by a prior managed order), and the drift
* warning the manual/tier branch shows is silently dropped.
* - `--reset` must actually strip residual priorities from the auth files, not
* just delete the stored config. With the proxy STOPPED the field is removed
* by a direct atomic write; the running-proxy PATCH path is covered at the
* clearDrainOrderPriorities unit level (drain-order.test.ts).
*/
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { handleOrderSubcommand } from '../order-subcommand';
describe('handleOrderSubcommand', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
function authDir(): string {
return path.join(tempHome, '.ccs', 'cliproxy', 'auth');
}
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(authDir(), fileName),
JSON.stringify({ type: 'claude', ...fields }, null, 2),
{ mode: 0o600 }
);
}
function readAuthFile(fileName: string): Record<string, unknown> {
return JSON.parse(fs.readFileSync(path.join(authDir(), fileName), 'utf-8')) as Record<
string,
unknown
>;
}
async function registerClaude(): Promise<{
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
}> {
return import(`../../../cliproxy/accounts/registry?order-subcommand=${Date.now()}`);
}
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') lines.push(msg);
});
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = 0;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('file-mode show with residual priorities', () => {
it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => {
// Residual on-disk priorities, no stored config -> file mode + drift.
// claude-a sorts first alphabetically, but b has the higher priority, so
// the selector drains b first. The display must follow the selector.
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
await handleOrderSubcommand(['claude']);
const output = lines.join('\n');
// b@x.com (priority 5) must appear before a@x.com (priority 1).
const idxB = output.indexOf('b@x.com');
const idxA = output.indexOf('a@x.com');
expect(idxB).toBeGreaterThanOrEqual(0);
expect(idxA).toBeGreaterThan(idxB);
// Drift surfaced (same as the manual/tier branch), and the mode label no
// longer falsely claims "no priority set" under residual priorities.
expect(output).toContain('Drift detected');
expect(output).toContain('residual priorities present');
expect(output).not.toContain('no priority set');
// [priority: N] annotations preserved.
expect(output).toContain('[priority: 5]');
expect(output).toContain('[priority: 1]');
});
it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
await handleOrderSubcommand(['claude']);
const output = lines.join('\n');
expect(output).toContain('no priority set');
expect(output).not.toContain('Drift detected');
});
});
describe('--reset clears residual priorities (proxy stopped -> direct write)', () => {
it('removes the priority field from auth files and reports per-file results', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 });
writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear
const { registerAccount, saveDrainOrderConfig } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
await handleOrderSubcommand(['claude', '--reset']);
// Residual priority is actually gone from disk (not just config deleted).
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
const output = lines.join('\n');
expect(output).toContain('reset to file order');
// Honest per-file reporting, and it no longer claims residuals remain.
expect(output).toContain('Cleared residual priority from 1 auth file');
expect(output).not.toContain('CLIProxy will continue using them');
});
it('reports already-clear files and still resets when no priorities exist', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
await handleOrderSubcommand(['claude', '--reset']);
const output = lines.join('\n');
expect(output).toContain('reset to file order');
expect(output).toContain('no priority set');
});
});
});
@@ -0,0 +1,224 @@
/**
* 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 { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
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');
// The pause label must NOT claim "manual" - a pause can be automatic (ban
// detection, cross-provider isolation, expired-but-unrestored cooldown).
expect(paused.label).toBe('paused (manual or safety)');
expect(paused.label).not.toBe('paused (manual)');
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');
});
});
// ---------------------------------------------------------------------------
// renderProviderPoolSection - integration (resume hint, file-mode drift copy,
// pool-on cooling honesty note). Uses an isolated CCS_HOME and captures console.
// ---------------------------------------------------------------------------
describe('renderProviderPoolSection', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
function writeAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(authDir, fileName),
JSON.stringify({ type: 'claude', ...fields }, null, 2),
{ mode: 0o600 }
);
}
const SETTINGS_OFF = {
poolEnabled: false,
strategy: 'round-robin',
sessionAffinityEnabled: false,
sessionAffinityTtl: '1h',
} as const;
const SETTINGS_ON = {
poolEnabled: true,
strategy: 'fill-first',
sessionAffinityEnabled: true,
sessionAffinityTtl: '1h',
} as const;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-renderer-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') lines.push(msg);
});
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = 0;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('emits a resume hint pointing at the real command when an account is paused', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
const { registerAccount, pauseAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-resume=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
pauseAccount('claude', 'a@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-resume=${Date.now()}`
);
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
const output = lines.join('\n');
// The hint names the actual resume subcommand (ccs cliproxy resume <account>).
expect(output).toContain('Resume:');
expect(output).toContain('ccs cliproxy resume <account>');
});
it('does NOT emit a resume hint when no account is paused', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-no-resume=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-no-resume=${Date.now()}`
);
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
expect(lines.join('\n')).not.toContain('Resume:');
});
it('uses honest file-mode drift copy (no "stored order") when residual priorities exist', async () => {
// Residual on-disk priorities, but no stored drain config -> file mode drift.
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
const { registerAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-drift=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-drift=${Date.now()}`
);
await renderProviderPoolSection('claude', SETTINGS_OFF, 1_000_000);
const output = lines.join('\n');
expect(output).toContain('Drift:');
// File mode has no stored order, so the copy must not claim one nor tell the
// user to "re-apply with --set/--by-tier" (which re-adopts managed ordering).
expect(output).toContain('residual priorities');
expect(output).toContain('--reset');
expect(output).not.toContain('stored order does not match');
expect(output).not.toContain('re-apply with --set/--by-tier');
});
it('prints the in-proxy-cooldown honesty note when pool is ON but no proxy data is available', async () => {
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await import(
`../../../cliproxy/accounts/registry?renderer-note=${Date.now()}`
);
registerAccount('claude', 'claude-a.json', 'a@x.com');
const { renderProviderPoolSection } = await import(
`../pool-state-renderer?renderer-note=${Date.now()}`
);
// No proxy is running in the test home, so the fetch degrades to no data and
// the honesty note must be printed instead of implying every account is fine.
await renderProviderPoolSection('claude', SETTINGS_ON, 1_000_000);
expect(lines.join('\n')).toContain('live in-proxy 429 cooldowns are not shown here');
});
});
@@ -0,0 +1,135 @@
/**
* Tests for handlePoolSubcommand: remote-target refusal and local lifecycle-port
* resolution.
*
* Why these matter:
* - Remote refusal: enable/disable only mutate LOCAL config files. On a remote
* target the running proxy is never touched, so flipping the local flag and
* printing a hot-reload success message would lie. The command must refuse
* (exitCode 1) and leave the local pool flag untouched.
* - Port resolution: a user on a custom local.port (e.g. 9000) must have
* config-9000.yaml regenerated (the file the running proxy reads), not the
* default config.yaml.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import { handlePoolSubcommand } from '../pool-subcommand';
import {
invalidateConfigCache as invalidateSharedConfigCache,
mutateConfig,
} from '../../../config/config-loader-facade';
function createTestHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-subcommand-test-'));
const ccsDir = path.join(dir, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'cliproxy', 'auth'), { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
return dir;
}
describe('handlePoolSubcommand', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
beforeEach(() => {
tempHome = createTestHome();
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
invalidateSharedConfigCache();
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
if (typeof msg === 'string') lines.push(msg);
});
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = 0;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('refuses --enable on a remote target, sets exitCode 1, and does not flip the local flag', async () => {
mutateConfig((cfg) => {
cfg.cliproxy_server = {
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
};
});
invalidateSharedConfigCache();
await handlePoolSubcommand(['--enable']);
const output = lines.join('\n');
expect(output).toContain('Remote proxy target detected');
// Manual-config guidance is shown for the enable path
expect(output).toContain('disable-cooling: false');
expect(output).toContain('strategy: fill-first');
expect(process.exitCode).toBe(1);
// Local pool flag must remain untouched (not enabled).
invalidateSharedConfigCache();
const { loadOrCreateUnifiedConfig } = await import(
`../../../config/config-loader-facade?poolremote=${Date.now()}`
);
const cfg = loadOrCreateUnifiedConfig();
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
// No local config.yaml should have been regenerated by the refused command.
const cliproxyConfig = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml');
expect(fs.existsSync(cliproxyConfig)).toBe(false);
});
it('refuses --disable on a remote target without the enable-only manual snippet', async () => {
mutateConfig((cfg) => {
cfg.cliproxy_server = {
remote: { enabled: true, host: '192.168.1.50', protocol: 'http' },
};
});
invalidateSharedConfigCache();
await handlePoolSubcommand(['--disable']);
const output = lines.join('\n');
expect(output).toContain('Remote proxy target detected');
// The disable refusal must NOT print the enable manual snippet
expect(output).not.toContain('disable-cooling: false');
expect(process.exitCode).toBe(1);
});
it('--enable on a local custom port regenerates config-<port>.yaml, not config.yaml', async () => {
// Configure a custom local lifecycle port. resolveLifecyclePort() must pick
// it up so the regenerated file matches what the running proxy reads.
mutateConfig((cfg) => {
cfg.cliproxy_server = { local: { port: 9000 } };
});
invalidateSharedConfigCache();
await handlePoolSubcommand(['--enable']);
const cliproxyDir = path.join(tempHome, '.ccs', 'cliproxy');
const customConfig = path.join(cliproxyDir, 'config-9000.yaml');
const defaultConfig = path.join(cliproxyDir, 'config.yaml');
// The custom-port config file must be the one regenerated.
expect(fs.existsSync(customConfig)).toBe(true);
// The default-port config.yaml must NOT be written by a custom-port enable.
expect(fs.existsSync(defaultConfig)).toBe(false);
// And the regenerated file must carry the pool-on rails.
const content = fs.readFileSync(customConfig, 'utf-8');
expect(content).toContain('disable-cooling: false');
expect(content).toContain('strategy: fill-first');
// Success path must not flag a failure exit code.
expect(process.exitCode).not.toBe(1);
});
});
+20 -1
View File
@@ -56,13 +56,32 @@ 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'],
['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'],
[
'accounts order <provider>',
'Show effective drain order (priority bucket desc, then ID asc)',
],
[
'accounts order <provider> --by-tier',
'Set drain order from tier metadata (ultra > pro > free)',
],
[
'accounts order <provider> --set a,b,c',
'Set manual drain order (comma-separated account IDs)',
],
['accounts order <provider> --reset', 'Revert drain order to stable file order'],
],
],
[
+22
View File
@@ -48,6 +48,8 @@ import {
handleCatalogReset,
handleCatalogJson,
} from './catalog-subcommand';
import { handlePoolSubcommand } from './pool-subcommand';
import { handleOrderSubcommand } from './order-subcommand';
/**
* Parse --backend flag from args
@@ -186,6 +188,26 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
if (command === 'pool') {
await handlePoolSubcommand(remainingArgs.slice(1));
return;
}
if (command === 'accounts') {
const subcommand = remainingArgs[1];
if (subcommand === 'order') {
await handleOrderSubcommand(remainingArgs.slice(2));
return;
}
// Unknown (or missing) accounts subcommand: report and show help.
// 'order' is currently the only accounts subcommand.
console.error(`[X] Unknown accounts subcommand: ${subcommand ?? '(none)'}`);
console.error(' Usage: ccs cliproxy accounts order <provider>');
process.exitCode = 1;
await showHelp();
return;
}
if (command === 'routing') {
const subcommand = remainingArgs[1];
if (subcommand === 'set') {
+675
View File
@@ -0,0 +1,675 @@
/**
* CLIProxy Accounts Drain Order Subcommand
*
* Handles:
* ccs cliproxy accounts order <provider>
* ccs cliproxy accounts order <provider> --by-tier
* ccs cliproxy accounts order <provider> --set a@x.com,b@y.com,...
* ccs cliproxy accounts order <provider> --reset
*/
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../../utils/ui';
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry';
import { getProviderAccounts } from '../../cliproxy/accounts/query';
import {
computeManualDrainOrder,
computeTierDrainOrder,
applyDrainOrder,
resolveEffectiveDrainOrder,
clearDrainOrderPriorities,
tieBreakKey,
type DrainOrderEntry,
type DrainOrderInput,
} from '../../cliproxy/accounts/drain-order';
import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector';
import { getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { AccountTier } from '../../cliproxy/accounts/types';
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']);
// 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') {
return dim('unknown');
}
const label =
tier === 'ultra' ? color(tier, 'success') : tier === 'pro' ? color(tier, 'info') : dim(tier);
return tierDerived ? label : dim(tier);
}
function printOrderTable(
entries: DrainOrderEntry[],
showCurrentPriority: boolean,
sortByFilePriority: boolean = false
): void {
const rows = entries.slice().sort((a, b) => {
if (sortByFilePriority) {
// Sort by currentPriority (the selector's actual input), treating undefined as 0.
// This is the priority the selector actually sees on disk, not the computed config priority.
const aCurrent = a.currentPriority ?? 0;
const bCurrent = b.currentPriority ?? 0;
return bCurrent - aCurrent || (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1);
}
// Tie-break by tokenFile ascending, matching the Go selector. Upstream sorts
// by Auth.ID byte order and only lowercases on Windows (see tieBreakKey).
// localeCompare is intentionally avoided - Go sorts by byte value, not locale.
return (
b.priority - a.priority || (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1)
);
});
const maxId = Math.max(...rows.map((r) => r.accountId.length), 'Account'.length);
const maxPri = Math.max(...rows.map((r) => String(r.priority).length), 'Priority'.length);
console.log(
` ${'#'.padStart(3)} ${color('Account'.padEnd(maxId), 'command')} ${'Priority'.padStart(maxPri)} Tier`
);
console.log(` ${'---'} ${'-'.repeat(maxId)} ${'-'.repeat(maxPri)} ----`);
rows.forEach((entry, idx) => {
const pos = String(idx + 1).padStart(3);
const id = entry.accountId.padEnd(maxId);
const pri = String(entry.priority).padStart(maxPri);
const tierLabel = formatTierLabel(entry.tier, entry.tierDerived);
const currentNote =
showCurrentPriority && entry.currentPriority !== undefined
? dim(` (file: ${entry.currentPriority})`)
: '';
console.log(
` ${pos} ${color(id, 'command')} ${color(pri, 'info')} ${tierLabel}${currentNote}`
);
});
}
/**
* Show effective drain order for a provider without making any changes.
*/
async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
// Use getProviderAccounts() so auth files not yet in accounts.json
// (e.g. file-copied fleets) are visible and tier metadata is preserved.
const allAccounts = getProviderAccounts(provider);
if (allAccounts.length === 0) {
console.log(warn(`No accounts found for provider: ${provider}`));
console.log('');
return;
}
const accounts: DrainOrderInput[] = allAccounts
.filter((a) => !a.paused)
.map((a) => ({
accountId: a.id,
tokenFile: a.tokenFile,
tier: a.tier,
}));
if (accounts.length === 0) {
console.log(warn(`All accounts for ${provider} are paused.`));
console.log('');
return;
}
// 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);
if (effective.mode === 'file') {
// File order: no priority writes. Render from the resolver's output so the
// displayed order matches what the selector actually drains, honouring any
// residual on-disk priorities (e.g. left by `order --reset`). Reached when
// no config is stored, or when a stored manual order has only stale IDs.
printFileOrderView(provider, effective.entries, effective.hasDrift);
return;
}
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) {
console.log('');
console.log(
warn(
`Drift detected: stored order does not match auth files; re-run --set/--by-tier to re-apply.`
)
);
}
console.log('');
// Show rows in selector pick order (currentPriority desc, tokenFile asc on tie).
// This reflects what CLIProxy will actually drain, not just the intended config.
console.log(subheader('Effective drain order (selector pick order, highest priority first):'));
printOrderTable(entries, true, true);
console.log('');
console.log(
dim(` To update: ccs cliproxy accounts order ${provider} --set <comma-separated-ids>`)
);
if (TIER_AWARE_PROVIDERS.has(provider)) {
console.log(dim(` Tier-based: ccs cliproxy accounts order ${provider} --by-tier`));
}
console.log(dim(` To reset: ccs cliproxy accounts order ${provider} --reset`));
console.log('');
}
function filePriorityNote(entry: DrainOrderEntry): string {
return entry.currentPriority !== undefined ? dim(` [priority: ${entry.currentPriority}]`) : '';
}
/**
* Render the file-order view from the shared resolver's output. No priority
* writes; used when no drain order config is stored, or when a stored manual
* order has only stale IDs.
*
* Entries arrive already sorted in selector pick order (on-disk priority desc,
* tokenFile asc on tie), so the displayed order matches what CLIProxy actually
* drains even when residual priorities exist (e.g. left by `order --reset`). The
* mode label and drift warning are kept honest for that residual case.
*/
function printFileOrderView(
provider: CLIProxyProvider,
entries: DrainOrderEntry[],
hasDrift: boolean
): void {
if (!TIER_AWARE_PROVIDERS.has(provider)) {
console.log(
info(
`Tier unknown for ${provider} accounts - using file order.\n` +
` Use --set to specify manual order.`
)
);
}
// Under drift, "no priority set" would be a lie - residual on-disk priorities
// are present and are what the selector follows. Label accordingly.
const label = hasDrift
? 'file order (residual priorities present)'
: 'file order (no priority set)';
console.log(` Mode: ${dim(label)}`);
if (hasDrift) {
console.log('');
console.log(
warn(
`Drift detected: residual on-disk priorities steer the selector;\n` +
` clear them with --reset (or re-auth the accounts) to return to plain file order.`
)
);
}
console.log('');
console.log(subheader('Active accounts (selector pick order):'));
// Entries are already in selector pick order from the resolver; render as-is.
entries.forEach((entry, idx) => {
const pri = filePriorityNote(entry);
console.log(` ${String(idx + 1).padStart(3)} ${color(entry.accountId, 'command')}${pri}`);
});
console.log('');
console.log(
dim(` To set manual order: ccs cliproxy accounts order ${provider} --set a@x.com,b@y.com`)
);
if (TIER_AWARE_PROVIDERS.has(provider)) {
console.log(
dim(` To use tier-based order: ccs cliproxy accounts order ${provider} --by-tier`)
);
}
console.log('');
}
/**
* Apply tier-derived drain order for a provider.
*/
async function handleOrderByTier(provider: CLIProxyProvider): Promise<void> {
if (!TIER_AWARE_PROVIDERS.has(provider)) {
console.log(
warn(
`Tier metadata is not available for ${provider} accounts.\n` +
` Tier is tracked for: ${[...TIER_AWARE_PROVIDERS].join(', ')}\n` +
` Use --set for manual order instead.`
)
);
console.log('');
process.exitCode = 1;
return;
}
// Use getProviderAccounts() so auth files not yet in accounts.json
// (e.g. file-copied fleets) are visible and tier metadata is preserved.
const allProviderAccounts = getProviderAccounts(provider);
if (allProviderAccounts.length === 0) {
console.log(warn(`No accounts found for provider: ${provider}`));
console.log('');
process.exitCode = 1;
return;
}
const accounts: DrainOrderInput[] = allProviderAccounts
.filter((a) => !a.paused)
.map((a) => ({
accountId: a.id,
tokenFile: a.tokenFile,
tier: a.tier,
}));
if (accounts.length === 0) {
console.log(warn(`All ${provider} accounts are paused.`));
console.log('');
process.exitCode = 1;
return;
}
const allUnknown = accounts.every((a) => !a.tier || a.tier === 'unknown');
if (allUnknown) {
console.log(
warn(
`All ${provider} accounts have unknown tier.\n` +
` Run quota to populate tier metadata, or use --set for manual order.`
)
);
console.log('');
process.exitCode = 1;
return;
}
const entries = computeTierDrainOrder(accounts);
console.log(subheader('Computed tier-based drain order:'));
printOrderTable(entries, false);
console.log('');
// Check proxy state for write path selection.
// Use the configured local port (not the hardcoded default) for detection.
// Remote targets: refuse with guidance - detection and current-priority reads
// require management API access that is not wired in v1.
const proxyTarget = getProxyTarget();
if (proxyTarget.isRemote) {
console.log(
warn(
`Remote proxy target detected. Drain order management for remote proxies is not\n` +
` supported in v1. Run this command on the host running CLIProxy instead.`
)
);
console.log('');
process.exitCode = 1;
return;
}
const localPort = resolveLifecyclePort();
const proxyStatus = await detectRunningProxy(localPort);
// Ambiguous state: proxy process is alive but not yet responding to HTTP.
// A direct file write while CLIProxy is serving could be silently clobbered
// by the MarkResult-persist path. Refuse rather than risk priority loss.
if (proxyStatus.running && !proxyStatus.verified) {
console.log(fail('Proxy state ambiguous - retry in a moment or stop the proxy first.'));
console.log('');
process.exitCode = 1;
return;
}
const proxyRunning = proxyStatus.running && proxyStatus.verified;
if (proxyRunning) {
console.log(info('Proxy is running - writing via management API (avoids clobber race).'));
} else {
console.log(info('Proxy is stopped - writing directly to auth files.'));
}
console.log('');
const result = await applyDrainOrder(entries, proxyRunning);
if (result.written.length > 0) {
console.log(ok(`Set priorities for ${result.written.length} account(s).`));
}
if (result.skipped.length > 0) {
console.log(info(`Skipped ${result.skipped.length} account(s) (priority already correct).`));
}
if (result.failed.length > 0) {
for (const f of result.failed) {
console.log(fail(`Failed to set priority for ${f.entry.accountId}: ${f.reason}`));
}
process.exitCode = 1;
}
if (result.failed.length === 0) {
// Persist mode
const persisted = saveDrainOrderConfig(provider, { mode: 'tier' });
if (persisted) {
console.log(
ok(
`Drain order mode saved as "tier". Re-run "ccs cliproxy accounts order ${provider} --by-tier" after adding or re-authing accounts to re-apply.`
)
);
} else {
console.log(
warn(
`Order applied to auth files but mode not persisted - no registered accounts for ${provider}; run ccs cliproxy quota to register, then re-run`
)
);
}
}
console.log('');
}
/**
* Apply manual drain order specified as comma-separated account IDs.
*/
async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promise<void> {
const orderedIds = setArg
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (orderedIds.length === 0) {
console.log(fail('--set requires a comma-separated list of account IDs.'));
console.log('');
process.exitCode = 1;
return;
}
// Use getProviderAccounts() so auth files not yet in accounts.json
// (e.g. file-copied fleets) are visible and tier metadata is preserved.
const allProviderAccounts = getProviderAccounts(provider);
if (allProviderAccounts.length === 0) {
console.log(warn(`No accounts found for provider: ${provider}`));
console.log('');
process.exitCode = 1;
return;
}
const allAccounts: DrainOrderInput[] = allProviderAccounts
.filter((a) => !a.paused)
.map((a) => ({
accountId: a.id,
tokenFile: a.tokenFile,
tier: a.tier,
}));
let entries: DrainOrderEntry[];
try {
entries = computeManualDrainOrder(orderedIds, allAccounts);
} catch (err) {
console.log(fail(`${(err as Error).message}`));
console.log('');
process.exitCode = 1;
return;
}
console.log(subheader('Computed manual drain order:'));
printOrderTable(entries, false);
console.log('');
// Use the configured local port (not the hardcoded default) for detection.
// Remote targets: refuse with guidance.
const proxyTarget = getProxyTarget();
if (proxyTarget.isRemote) {
console.log(
warn(
`Remote proxy target detected. Drain order management for remote proxies is not\n` +
` supported in v1. Run this command on the host running CLIProxy instead.`
)
);
console.log('');
process.exitCode = 1;
return;
}
const localPort = resolveLifecyclePort();
const proxyStatus = await detectRunningProxy(localPort);
// Ambiguous state: proxy process is alive but not yet responding to HTTP.
// A direct file write while CLIProxy is serving could be silently clobbered
// by the MarkResult-persist path. Refuse rather than risk priority loss.
if (proxyStatus.running && !proxyStatus.verified) {
console.log(fail('Proxy state ambiguous - retry in a moment or stop the proxy first.'));
console.log('');
process.exitCode = 1;
return;
}
const proxyRunning = proxyStatus.running && proxyStatus.verified;
if (proxyRunning) {
console.log(info('Proxy is running - writing via management API (avoids clobber race).'));
} else {
console.log(info('Proxy is stopped - writing directly to auth files.'));
}
console.log('');
const result = await applyDrainOrder(entries, proxyRunning);
if (result.written.length > 0) {
console.log(ok(`Set priorities for ${result.written.length} account(s).`));
}
if (result.skipped.length > 0) {
console.log(info(`Skipped ${result.skipped.length} account(s) (priority already correct).`));
}
if (result.failed.length > 0) {
for (const f of result.failed) {
console.log(fail(`Failed to set priority for ${f.entry.accountId}: ${f.reason}`));
}
process.exitCode = 1;
}
if (result.failed.length === 0) {
const persisted = saveDrainOrderConfig(provider, { mode: 'manual', orderedIds });
if (persisted) {
console.log(
ok(
`Drain order mode saved as "manual". Re-run "ccs cliproxy accounts order ${provider} --set <ids>" after adding or re-authing accounts to re-apply.`
)
);
} else {
console.log(
warn(
`Order applied to auth files but mode not persisted - no registered accounts for ${provider}; run ccs cliproxy quota to register, then re-run`
)
);
}
}
console.log('');
}
/**
* Reset drain order to file order: remove the persisted config AND clear the
* residual priority field from the provider's auth files so the selector
* actually returns to plain file order.
*
* Clearing the field uses the same dual write path as --set/--by-tier:
* - Proxy running: PATCH priority:0 via the management API (the management layer
* treats 0 as delete). A direct file write here would be clobbered by the
* proxy's whole-file MarkResult persist, so the API path is mandatory.
* - Proxy stopped: delete the field directly with an atomic temp-rename.
*
* Remote targets are refused with guidance (same as --set/--by-tier), and an
* ambiguous proxy state (alive but not yet serving HTTP) is refused to avoid a
* clobbered write.
*/
async function handleOrderReset(provider: CLIProxyProvider): Promise<void> {
const configCleared = clearDrainOrderConfig(provider);
// Collect the auth files that may carry residual priorities. Use
// getProviderAccounts() so file-copied fleets (not yet in accounts.json) are
// covered too; clearing is idempotent for files with no priority field.
const tokenFiles = getProviderAccounts(provider).map((a) => a.tokenFile);
if (tokenFiles.length === 0) {
if (configCleared) {
console.log(ok(`Drain order reset to file order for ${provider}.`));
} else {
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
}
console.log('');
return;
}
// Remote targets: refuse with guidance - clearing priorities needs management
// API access that is not wired for remote proxies in v1.
const proxyTarget = getProxyTarget();
if (proxyTarget.isRemote) {
if (configCleared) {
console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`));
} else {
console.log(info(`No drain order config found for ${provider}.`));
}
console.log(
warn(
`Remote proxy target detected. Residual auth-file priorities were NOT cleared;\n` +
` drain order management for remote proxies is not supported in v1. Run this\n` +
` command on the host running CLIProxy to clear them.`
)
);
console.log('');
process.exitCode = 1;
return;
}
const localPort = resolveLifecyclePort();
const proxyStatus = await detectRunningProxy(localPort);
// Ambiguous state: proxy alive but not yet serving HTTP. A direct write could
// be clobbered; refuse rather than risk a half-cleared state.
if (proxyStatus.running && !proxyStatus.verified) {
if (configCleared) {
console.log(ok(`Drain order config cleared for ${provider} (mode reset to file order).`));
}
console.log(
fail(
'Proxy state ambiguous - residual priorities not cleared. Retry in a moment or stop the proxy first.'
)
);
console.log('');
process.exitCode = 1;
return;
}
const proxyRunning = proxyStatus.running && proxyStatus.verified;
if (proxyRunning) {
console.log(
info('Proxy is running - clearing priorities via management API (avoids clobber race).')
);
} else {
console.log(info('Proxy is stopped - clearing priorities directly in auth files.'));
}
const result = await clearDrainOrderPriorities(tokenFiles, proxyRunning);
if (configCleared || result.cleared.length > 0) {
console.log(ok(`Drain order reset to file order for ${provider}.`));
} else {
console.log(info(`No drain order config found for ${provider}. Already in file order.`));
}
if (result.cleared.length > 0) {
console.log(ok(`Cleared residual priority from ${result.cleared.length} auth file(s).`));
}
if (result.alreadyClear.length > 0) {
console.log(
info(`${result.alreadyClear.length} auth file(s) had no priority set (nothing to clear).`)
);
}
if (result.failed.length > 0) {
for (const f of result.failed) {
console.log(fail(`Failed to clear priority for ${f.tokenFile}: ${f.reason}`));
}
console.log(
dim(
' Residual priorities remain on the failed files above and will still steer the selector.'
)
);
process.exitCode = 1;
}
console.log('');
}
function printOrderHelp(provider?: string): void {
const prov = provider ?? '<provider>';
console.log(subheader('Usage:'));
console.log(` ${color(`ccs cliproxy accounts order ${prov}`, 'command')}`);
console.log(` ${color(`ccs cliproxy accounts order ${prov} --by-tier`, 'command')}`);
console.log(` ${color(`ccs cliproxy accounts order ${prov} --set a@x.com,b@y.com`, 'command')}`);
console.log(` ${color(`ccs cliproxy accounts order ${prov} --reset`, 'command')}`);
console.log('');
console.log(subheader('Options:'));
console.log(` ${dim('(no flags)')} Show effective drain order`);
console.log(
` ${color('--by-tier', 'command')} Derive order from tier metadata (ultra > pro > free)`
);
console.log(
` ${color('--set', 'command')} <ids> Set manual order (comma-separated account IDs)`
);
console.log(` ${color('--reset', 'command')} Revert to stable file order`);
console.log('');
console.log(dim(' Tier-based ordering is available for: agy, gemini'));
console.log(dim(' Claude accounts have unknown tier; use --set for manual order.'));
console.log('');
}
/**
* Main handler for `ccs cliproxy accounts order <provider> [flags]`
*/
export async function handleOrderSubcommand(args: string[]): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Drain Order'));
console.log('');
if (hasAnyFlag(args, ['--help', '-h']) || args.length === 0) {
printOrderHelp();
return;
}
// First positional arg is provider name
const providerRaw = args[0];
if (!providerRaw || providerRaw.startsWith('-')) {
console.log(fail('Provider name required. Usage: ccs cliproxy accounts order <provider>'));
console.log('');
printOrderHelp();
process.exitCode = 1;
return;
}
const provider = mapExternalProviderName(providerRaw) as CLIProxyProvider | null;
if (!provider) {
console.log(fail(`Unknown provider: ${providerRaw}`));
console.log('');
process.exitCode = 1;
return;
}
const subArgs = args.slice(1);
if (hasAnyFlag(subArgs, ['--reset'])) {
await handleOrderReset(provider);
return;
}
if (hasAnyFlag(subArgs, ['--by-tier'])) {
await handleOrderByTier(provider);
return;
}
const extracted = extractOption(subArgs, ['--set']);
if (extracted.found) {
if (extracted.missingValue || !extracted.value) {
console.log(fail('--set requires a value: --set a@x.com,b@y.com,...'));
console.log('');
process.exitCode = 1;
return;
}
await handleOrderSet(provider, extracted.value);
return;
}
// Default: show current order
await handleOrderShow(provider);
}
@@ -0,0 +1,271 @@
/**
* 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 {
resolvePoolStateWithProxyCooldowns,
hasProxySourcedCooling,
POOL_TIER_AWARE_PROVIDERS,
type PoolAccountState,
type PoolRoutingSettings,
type PoolDrainOrderMode,
type PoolState,
} 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':
// A pause can be manual (the user) OR automatic (ban detection,
// cross-provider isolation, an expired-but-not-restored quota cooldown).
// The classifier cannot tell which without a stored reason, so the label
// must not claim "manual". See pool-state.ts classifyAccount.
return { label: 'paused (manual or safety)', 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 drift warning. The honest copy depends on whether a stored order
* exists: in file mode there is no stored order (the user reset or never set
* one), so referencing a "stored order" or telling them to "re-apply" would be
* false. File-mode drift means residual on-disk priorities (e.g. left by a prior
* managed order) still steer the selector, and the fix is to clear them.
*/
function renderDrainOrderDrift(provider: CLIProxyProvider, pool: PoolState): void {
if (!pool.drainOrder.hasDrift) {
return;
}
if (pool.drainOrder.mode === 'file') {
console.log(
` ${warn(
`Drift: residual priorities from a previous managed order still steer the selector;\n` +
` clear them with "ccs cliproxy accounts order ${provider} --reset" (or re-auth the accounts).`
)}`
);
return;
}
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.`
)}`
);
}
/**
* Render the honesty note about cooling visibility.
*
* - Pool OFF: cooling is disabled at the proxy (stock stability mode), so no
* account ever shows a quota cooldown here. State that plainly.
* - Pool ON but no live proxy reading available (proxy stopped/older binary, or
* the listing returned no cooling entries): the in-proxy 429 cooldowns that
* actually drive session hops are not visible, so say so rather than implying
* every account is healthy.
*/
function renderCoolingNote(pool: PoolState, settings: PoolRoutingSettings): void {
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.'
)
);
}
return;
}
// Pool ON: only omit the caveat once we actually have live proxy-sourced
// cooling data to show. Otherwise the states reflect CCS-side checks only.
if (!hasProxySourcedCooling(pool)) {
console.log(
dim(
' Note: live in-proxy 429 cooldowns are not shown here; states reflect CCS-side checks only.'
)
);
}
}
/**
* Render the pool context section for one provider. Returns silently (renders
* nothing) when the provider has no accounts, so quota output stays clean.
*
* Async because it folds in live in-proxy 429 cooldowns (the real session-hop
* mechanism when pool routing is ON) via a single bounded management API read;
* that read degrades silently when the proxy is stopped, remote, or older.
*/
export async function renderProviderPoolSection(
provider: CLIProxyProvider,
settings: PoolRoutingSettings,
now: number = Date.now()
): Promise<void> {
const pool = await resolvePoolStateWithProxyCooldowns({ 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)')}`);
}
renderDrainOrderDrift(provider, pool);
// 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}`);
}
renderCoolingNote(pool, settings);
// Resume hint: a pause can be manual OR automatic (ban detection,
// cross-provider isolation, expired-but-unrestored quota cooldown). Point the
// user at the real resume command so they can recover whichever caused it.
if (pool.states.some((s) => s.state === 'paused')) {
console.log(` ${dim(`Resume: ccs cliproxy resume <account>`)}`);
}
// 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('');
}
+121
View File
@@ -0,0 +1,121 @@
/**
* 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,
POOL_MAX_RETRY_CREDENTIALS,
} from '../../cliproxy/routing/routing-strategy';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
import { getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
import { getConfigPathForPort, getAuthDir } from '../../cliproxy/config/path-resolver';
import { hasAnyFlag } from '../arg-extractor';
/**
* Print the manual-config guidance for remote/Docker targets, then refuse.
* enablePoolRouting/disablePoolRouting only write local config files, so a
* remote proxy is never touched refuse rather than lie about hot-reload.
* Mirrors the remote-refusal copy in order-subcommand and the manual snippet
* in pool-opt-in-prompt's printRemoteHint().
*/
function printRemotePoolRefusal(enable: boolean): void {
console.log(
warn(
`Remote proxy target detected. Pool routing management for remote proxies is not\n` +
` supported in v1. Run this command on the host running CLIProxy instead.`
)
);
console.log('');
if (enable) {
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 async function handlePoolSubcommand(args: string[]): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Pool Routing'));
console.log('');
const wantsEnable = hasAnyFlag(args, ['--enable']);
const wantsDisable = hasAnyFlag(args, ['--disable']);
// Resolve the real proxy target before any config write. Remote targets are
// refused (enable/disable only mutate local files; the remote proxy is never
// touched, so the "hot-reload" success message would be a lie). Local targets
// use the configured lifecycle port so the regenerated config-<port>.yaml is
// the same file the running proxy reads.
if (wantsEnable || wantsDisable) {
const target = getProxyTarget();
if (target.isRemote) {
printRemotePoolRefusal(wantsEnable);
process.exitCode = 1;
return;
}
}
const port = resolveLifecyclePort();
const configPath = getConfigPathForPort(port);
const authDir = getAuthDir();
if (wantsEnable) {
const result = enablePoolRouting(port, { configPath, authDir });
if (result.failed) {
console.log(warn(result.message));
process.exitCode = 1;
} else if (result.changed) {
console.log(ok(result.message));
} else {
console.log(info(result.message));
}
console.log('');
return;
}
if (wantsDisable) {
const result = disablePoolRouting(port, { configPath, authDir });
if (result.failed) {
console.log(warn(result.message));
process.exitCode = 1;
} else 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('');
}
@@ -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,10 @@ 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.
// Async: folds in live in-proxy 429 cooldowns when pool routing is on.
await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
continue;
}
+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,
+6 -1
View File
@@ -122,12 +122,17 @@ export async function showProviderShortcutHelp(
writeLine('');
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
writeLine('');
const configSummary =
provider === 'claude'
? 'Pin a model for this session (optional; use /model inside Claude Code instead)'
: 'Open the provider config flow';
writeCommandTable(
'Common Commands',
[
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
{ name: `ccs ${provider} --config`, summary: configSummary },
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
],
writeLine
+38
View File
@@ -122,6 +122,42 @@ 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;
/**
* Whether the user has dismissed the pool onboarding hint.
* Fires once when >= 2 native Claude profiles exist and no pool is enabled.
* Shares the pool_routing key family - one schema home, no duplicate plumbing.
*/
onboarding_hint_dismissed?: boolean;
}
/**
* CLIProxy configuration section.
*/
@@ -150,4 +186,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;
}
+3
View File
@@ -191,6 +191,9 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
* Merges with defaults to ensure all sections exist.
*/
export function loadOrCreateUnifiedConfig(): UnifiedConfig {
// Read-only: "create" means an in-memory default object when config.yaml is
// absent. This never writes to disk, so callers on legacy installs can use
// it for read paths without implicitly creating config.yaml.
const existing = loadUnifiedConfig();
if (existing) {
const merged = mergeWithDefaults(existing);
+11
View File
@@ -10,6 +10,8 @@ import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning
import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy';
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
import type { ProfileDispatchContext } from '../dispatcher-context';
import { maybeShowPoolOnboardingHint } from '../../cliproxy/routing/pool-onboarding-hint';
import { hasUnifiedConfig } from '../../config/config-loader-facade';
export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
@@ -56,6 +58,15 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void>
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, nativeClaudeRemainingArgs);
// One-time pool onboarding hint: fires when >= 2 native Claude profiles exist
// and pool routing is not yet enabled. Print-only, TTY-gated, never blocks spawn.
// Gated on hasUnifiedConfig() so legacy profiles.json-only installs receive the
// hint from ccs doctor only (where dismissal semantics are preserved).
if (hasUnifiedConfig()) {
maybeShowPoolOnboardingHint();
}
const launchArgs = resolveNativeClaudeLaunchArgs(
nativeClaudeRemainingArgs,
'account',
+11
View File
@@ -17,6 +17,7 @@ import {
} from './checks';
import { runAutoRepair } from './repair';
import { getDockerKeyRotationStatus } from '../docker/docker-key-rotation';
import { maybeShowPoolOnboardingHint } from '../cliproxy/routing/pool-onboarding-hint';
/**
* Doctor Class - Orchestrates health checks
@@ -157,6 +158,16 @@ class Doctor {
console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`));
}
// Pool onboarding hint: fires when >= 2 native Claude profiles exist and
// pool routing is not yet enabled. TTY-gated and never blocks (failures are
// swallowed inside maybeShowPoolOnboardingHint). This is the deliberately
// ungated discovery surface for legacy profiles.json-only installs, which
// have no config.yaml to persist dismissal: those users may see the hint on
// every doctor run until they run `ccs migrate` or enable pool routing.
// Unified installs persist dismissal in config.yaml, so for them it is
// genuinely once per install.
maybeShowPoolOnboardingHint();
console.log('');
}
@@ -18,7 +18,10 @@ describe('pr ci workflow', () => {
expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]');
// main and dev must always be covered; long-lived epic branches may be
// appended temporarily (e.g. kai/feat/1464-account-pools) so phase PRs
// targeting the epic get full CI.
expect(workflow).toMatch(/branches: \[main, dev(?:, [^\]]+)?\]/);
// 4 jobs: validate (matrix), build, test, compose-parity — each gated
expect(workflow.split(trustedAuthorGate).length - 1).toBe(4);
expect(workflow).toContain('group: ci-${{ github.ref }}');
@@ -46,6 +46,14 @@ export function RoutingGuidanceCard({
}: RoutingGuidanceCardProps) {
const { t } = useTranslation();
const currentStrategy = state?.strategy ?? 'round-robin';
const poolEnabled = state?.poolRouting?.enabled ?? false;
const poolMaxRetry = state?.poolRouting?.maxRetryCredentials;
// Undefined manageable means local (managed); only false signals a remote
// proxy where the local pool flag may not reflect the running proxy.
const poolManageable = state?.poolRouting?.manageable ?? true;
const poolLocalOnly = !poolManageable;
const poolLocalOnlyMessage =
state?.poolRouting?.message ?? t('routingGuidance.poolRoutingLocalOnly');
const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
@@ -172,6 +180,48 @@ export function RoutingGuidanceCard({
</div>
</div>
{poolEnabled && poolManageable ? (
<div
role="status"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-4 text-amber-700 dark:text-amber-300"
>
{t('routingGuidance.poolRoutingApplyWarning')}
</div>
) : null}
<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">
{poolLocalOnly
? poolLocalOnlyMessage
: poolEnabled
? t('routingGuidance.drainOrderHint')
: t('routingGuidance.poolRoutingOffHint')}
</div>
</div>
<Badge
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
title={
poolLocalOnly
? poolLocalOnlyMessage
: poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
}
>
{poolLocalOnly
? t('routingGuidance.localOnly')
: poolEnabled
? `${t('routingGuidance.poolRoutingOn')} · ${t('routingGuidance.poolMaxRetry', {
count: poolMaxRetry ?? 0,
})}`
: 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 +290,36 @@ 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={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
title={
poolLocalOnly
? poolLocalOnlyMessage
: poolEnabled
? t('routingGuidance.poolRoutingManaged')
: t('routingGuidance.poolRoutingOffHint')
}
>
{t('routingGuidance.poolRouting')}:{' '}
{poolLocalOnly
? t('routingGuidance.localOnly')
: poolEnabled
? t('routingGuidance.poolRoutingOn')
: t('routingGuidance.poolRoutingOff')}
{!poolLocalOnly && 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">
@@ -293,6 +368,14 @@ export function RoutingGuidanceCard({
{isSaving ? 'Saving...' : `Use ${selected}`}
</Button>
</div>
{poolEnabled && poolManageable ? (
<p
role="status"
className="max-w-xs text-[11px] leading-4 text-amber-700 dark:text-amber-400 xl:text-right"
>
{t('routingGuidance.poolRoutingApplyWarning')}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
+15
View File
@@ -509,12 +509,27 @@ export interface AuthStatus {
export type RoutingStrategy = 'round-robin' | 'fill-first';
export interface CliproxyPoolRoutingState {
enabled: boolean;
maxRetryCredentials?: number;
/**
* Whether the pool flag reflects the actual proxy. For a remote proxy target
* the pool flag is local-only and may not be applied to the remote, so the
* backend sets this to false. Undefined is treated as manageable (local).
*/
manageable?: boolean;
/** Explanation surfaced when manageable is false (remote target). */
message?: string;
}
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 {
+69
View File
@@ -1917,6 +1917,20 @@ 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.',
poolRoutingApplyWarning:
'Pool routing is on. Strategy and session affinity changes will not take effect until you disable it: ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'Pool routing is managed locally; this remote proxy may not reflect it.',
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 +4556,19 @@ const resources = {
roundRobin: '轮询模式均匀分配用量。',
fillFirst: '优先填满模式让备用账号保持冷启动直到需要时。',
routingStrategy: '路由策略',
poolRouting: '账号池路由',
poolRoutingOn: '开启',
poolRoutingOff: '关闭',
poolRoutingManaged:
'账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。',
poolRoutingOffHint:
'账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。',
poolRoutingApplyWarning:
'账号池路由已开启。在禁用之前,策略和会话粘性的更改不会生效:ccs cliproxy pool --disable',
poolRoutingLocalOnly: '账号池路由仅在本地管理;此远程代理可能不会反映该设置。',
poolMaxRetry: '最大重试 {{count}}',
drainOrderHint:
'查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order <provider>。',
optionalRouting: '可选路由',
sessionAffinity: '会话粘性',
sessionAffinityOn: '开启',
@@ -7230,6 +7257,20 @@ 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.',
poolRoutingApplyWarning:
'Định tuyến nhóm đang bật. Thay đổi chiến lược và ghim phiên sẽ không có hiệu lực cho đến khi bạn tắt nó: ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'Định tuyến nhóm chỉ được quản lý cục bộ; proxy từ xa này có thể không phản ánh điều đó.',
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 +10436,20 @@ const resources = {
roundRobin: 'ラウンドロビンで利用を分散します。',
fillFirst: 'Fill first は、バックアップアカウントが必要になるまで待機させます。',
routingStrategy: 'ルーティング戦略',
poolRouting: 'プールルーティング',
poolRoutingOn: 'オン',
poolRoutingOff: 'オフ',
poolRoutingManaged:
'プールルーティングが有効です。CCS がプロキシ全体の戦略(fill-first)、セッション固定、クールダウンを管理します。',
poolRoutingOffHint:
'プールルーティングは無効です。CLIccs cliproxy pool --enable)で有効にすると、クールダウン付きの fill-first を利用できます。',
poolRoutingApplyWarning:
'プールルーティングが有効です。無効化するまで、戦略とセッション固定の変更は反映されません:ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'プールルーティングはローカルで管理されます。このリモートプロキシには反映されない場合があります。',
poolMaxRetry: '最大リトライ {{count}}',
drainOrderHint:
'アカウントごとのドレイン順序とクールダウン状態は、ccs cliproxy quota または ccs cliproxy accounts order <provider> を実行してください。',
optionalRouting: 'オプションのルーティング',
sessionAffinity: 'セッション固定',
sessionAffinityOn: 'オン',
@@ -12657,6 +12712,20 @@ const resources = {
roundRobin: '라운드 로빈은 사용량을 분산합니다.',
fillFirst: '먼저 채우기는 필요할 때까지 백업 계정을 차갑게 유지합니다.',
routingStrategy: '라우팅 전략',
poolRouting: '풀 라우팅',
poolRoutingOn: '켜짐',
poolRoutingOff: '꺼짐',
poolRoutingManaged:
'풀 라우팅이 켜져 있습니다. CCS가 프록시 전체의 전략(fill-first), 세션 어피니티, 쿨다운을 관리합니다.',
poolRoutingOffHint:
'풀 라우팅이 꺼져 있습니다. CLI(ccs cliproxy pool --enable)에서 켜면 쿨다운이 있는 fill-first를 사용할 수 있습니다.',
poolRoutingApplyWarning:
'풀 라우팅이 켜져 있습니다. 비활성화하기 전에는 전략과 세션 어피니티 변경이 적용되지 않습니다: ccs cliproxy pool --disable',
poolRoutingLocalOnly:
'풀 라우팅은 로컬에서 관리됩니다. 이 원격 프록시에는 반영되지 않을 수 있습니다.',
poolMaxRetry: '최대 재시도 {{count}}',
drainOrderHint:
'계정별 드레인 순서와 쿨다운 상태는 ccs cliproxy quota 또는 ccs cliproxy accounts order <provider>를 실행하세요.',
optionalRouting: '선택적 라우팅',
sessionAffinity: '세션 어피니티',
sessionAffinityOn: '켜짐',
@@ -87,4 +87,109 @@ describe('RoutingGuidanceCard', () => {
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled();
});
it('compact: pool ON shows the drain-order pointer and an inline override warning', () => {
render(
<RoutingGuidanceCard
compact
state={{
strategy: 'fill-first',
source: 'live',
target: 'local',
reachable: true,
poolRouting: { enabled: true, maxRetryCredentials: 3 },
}}
sessionAffinityState={{
enabled: true,
ttl: '1h',
source: 'config',
target: 'local',
reachable: true,
manageable: true,
}}
isLoading={false}
isSaving={false}
onApply={() => undefined}
onApplyAffinity={() => undefined}
/>
);
// Drain-order CLI pointer is visible (not hidden behind a hover tooltip).
expect(
screen.getByText(/ccs cliproxy quota or ccs cliproxy accounts order/i)
).toBeInTheDocument();
// Max retry stays visible in the badge.
expect(screen.getByText(/On · Max retry 3/i)).toBeInTheDocument();
// Static override warning is rendered before the user clicks anything.
expect(
screen.getByText(/will not take effect until you disable it: ccs cliproxy pool --disable/i)
).toBeInTheDocument();
});
it('compact: pool OFF surfaces the how-to-enable command as visible text', () => {
render(
<RoutingGuidanceCard
compact
state={{
strategy: 'round-robin',
source: 'live',
target: 'local',
reachable: true,
poolRouting: { enabled: false },
}}
sessionAffinityState={{
enabled: false,
ttl: '1h',
source: 'config',
target: 'local',
reachable: true,
manageable: true,
}}
isLoading={false}
isSaving={false}
onApply={() => undefined}
onApplyAffinity={() => undefined}
/>
);
// The enable command is visible, not buried in a hover-only tooltip.
expect(screen.getByText(/ccs cliproxy pool --enable/i)).toBeInTheDocument();
// No override warning when pool is off.
expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument();
});
it('compact: remote pool (manageable false) shows a local-only note, not a confident On/Off', () => {
render(
<RoutingGuidanceCard
compact
state={{
strategy: 'round-robin',
source: 'live',
target: 'remote',
reachable: true,
poolRouting: {
enabled: true,
manageable: false,
message: 'Pool routing is managed locally; this remote proxy may not reflect it.',
},
}}
sessionAffinityState={{
source: 'unsupported',
target: 'remote',
reachable: true,
manageable: false,
}}
isLoading={false}
isSaving={false}
onApply={() => undefined}
onApplyAffinity={() => undefined}
/>
);
expect(
screen.getByText('Pool routing is managed locally; this remote proxy may not reflect it.')
).toBeInTheDocument();
// No override warning for a remote proxy the local flag does not manage.
expect(screen.queryByText(/ccs cliproxy pool --disable/i)).not.toBeInTheDocument();
});
});