mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(',') }
|
||||
: {}),
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, infoBox, warn } from '../../utils/ui';
|
||||
import { extractOption } from '../arg-extractor';
|
||||
import {
|
||||
applyCliproxyRoutingStrategy,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readCliproxyRoutingState,
|
||||
readCliproxySessionAffinityState,
|
||||
} from '../../cliproxy/routing/routing-strategy';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
|
||||
function printStrategyGuide(): void {
|
||||
console.log(subheader('Routing Modes:'));
|
||||
@@ -134,6 +135,20 @@ export async function handleRoutingSet(args: string[]): Promise<void> {
|
||||
console.log(header('Update CLIProxy Routing'));
|
||||
console.log('');
|
||||
|
||||
// Pool routing is active: the generator bypasses stored routing and emits
|
||||
// fill-first/affinity regardless. Storing a strategy here creates a divergence
|
||||
// between the unified config and the emitted config.yaml — warn the user.
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
if (config.cliproxy?.pool_routing?.enabled === true) {
|
||||
console.log(
|
||||
warn(
|
||||
'[!] Pool routing is active. The stored strategy will not take effect\n' +
|
||||
' until pool routing is disabled: ccs cliproxy pool --disable'
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const result = await applyCliproxyRoutingStrategy(requested);
|
||||
console.log(ok(`Routing strategy set to ${requested}`));
|
||||
console.log(` Applied: ${color(result.applied, 'info')}`);
|
||||
@@ -223,6 +238,20 @@ export async function handleRoutingAffinitySet(args: string[]): Promise<void> {
|
||||
console.log(header('Update CLIProxy Session Affinity'));
|
||||
console.log('');
|
||||
|
||||
// Pool routing is active: the generator bypasses stored session-affinity and emits
|
||||
// affinity:true/1h regardless. Storing a value here creates a divergence between
|
||||
// the unified config and the emitted config.yaml — warn the user.
|
||||
const affinityConfig = loadOrCreateUnifiedConfig();
|
||||
if (affinityConfig.cliproxy?.pool_routing?.enabled === true) {
|
||||
console.log(
|
||||
warn(
|
||||
'[!] Pool routing is active. The stored affinity setting will not take effect\n' +
|
||||
' until pool routing is disabled: ccs cliproxy pool --disable'
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const result = await applyCliproxySessionAffinitySettings({
|
||||
enabled: requested,
|
||||
ttl,
|
||||
|
||||
@@ -122,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
'プールルーティングは無効です。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: 'オン',
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user