mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
fix(cliproxy): harden account pools per pre-merge usefulness review
- pool --enable/--disable: refuse remote targets with manual config guidance; resolve the lifecycle port instead of assuming 8317 - enablePoolRouting: roll back the pool flag when config regeneration fails; already-enabled path re-runs regeneration (repairable state) - pool opt-in prompt: gate on hasUnifiedConfig (legacy installs skip); never auto-accept consent under --yes/CCS_YES - order show (file mode): render selector pick order incl. residual on-disk priorities and surface drift instead of alphabetical order - order --reset: clear residual priority fields via management-API PATCH when proxy runs, atomic direct write when stopped - quota pool section: classify in-proxy 429 cooldowns as cooling with reset times (graceful degradation when proxy or endpoint is absent); honest paused label plus resume hint - routing state: remote targets report pool as not manageable instead of echoing the local flag; strategy/affinity apply warns when pool routing overrides the change (CLI, API message, and dashboard) - claude model-neutral: --config explicit pins survive the stale-pin migration; remote env path filters historical default pins read from claude.settings.json without mutating the file - cross-lane guard: also checks account-profile CLAUDE_CONFIG_DIR lanes for email overlap, not only the ambient ~/.claude login - onboarding hint: opt-in copy naming ccs cliproxy pool --enable - dashboard card: pool-ON shows drain-order pointer, pool-OFF shows the enable command, local-only note for remote proxies (i18n x5)
This commit is contained in:
@@ -383,8 +383,8 @@ describe('Phase 5: Pool Onboarding Hint', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── 13. Hint copy includes 'ccs claude' and quota trade-off language ──────
|
||||
it("hint copy mentions 'ccs claude' and quota trade-off", async () => {
|
||||
// ── 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']);
|
||||
|
||||
@@ -397,7 +397,12 @@ describe('Phase 5: Pool Onboarding Hint', () => {
|
||||
expect(result.printed).toBe(true);
|
||||
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(allOutput).toContain('ccs claude');
|
||||
expect(allOutput).toContain('share quota');
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -793,6 +793,118 @@ describe('Phase 3: cross-lane email overlap guard', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── Account-profile lane enumeration (isolated CLAUDE_CONFIG_DIR lanes) ──
|
||||
// The ambient ~/.claude check alone misses the isolated lanes of CCS account
|
||||
// profiles — exactly the multi-account population the guard protects. These
|
||||
// tests prove the guard also reads each profile lane's .claude.json email.
|
||||
|
||||
/** Write a profiles.json account entry + its instance-lane .claude.json email. */
|
||||
function seedAccountLane(ccsDir: string, name: string, email: string | null): void {
|
||||
const profilesPath = path.join(ccsDir, 'profiles.json');
|
||||
let payload: { version: string; profiles: Record<string, unknown>; default: string | null };
|
||||
try {
|
||||
payload = JSON.parse(fs.readFileSync(profilesPath, 'utf-8'));
|
||||
} catch {
|
||||
payload = { version: '2.0.0', profiles: {}, default: null };
|
||||
}
|
||||
payload.profiles[name] = {
|
||||
type: 'account',
|
||||
created: new Date().toISOString(),
|
||||
last_used: null,
|
||||
};
|
||||
fs.writeFileSync(profilesPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
|
||||
const instanceDir = path.join(ccsDir, 'instances', name);
|
||||
fs.mkdirSync(instanceDir, { recursive: true });
|
||||
if (email !== null) {
|
||||
fs.writeFileSync(
|
||||
path.join(instanceDir, '.claude.json'),
|
||||
JSON.stringify({ oauthAccount: { emailAddress: email } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
it('warns when a CCS account-profile lane email matches, even though ambient ~/.claude is logged out', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
seedAccountLane(ccsDir, 'work', 'lane@example.com');
|
||||
|
||||
// Ambient default lane is logged OUT — old guard would stay silent.
|
||||
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
|
||||
loggedIn: false,
|
||||
email: null,
|
||||
authMethod: null,
|
||||
apiProvider: null,
|
||||
orgId: null,
|
||||
orgName: null,
|
||||
subscriptionType: null,
|
||||
});
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
|
||||
if (typeof msg === 'string') stderrOutput.push(msg);
|
||||
});
|
||||
|
||||
checkCrossLaneEmailOverlap('agy', 'lane@example.com');
|
||||
|
||||
const combined = stderrOutput.join('\n');
|
||||
expect(combined).toContain('cross-lane email overlap');
|
||||
// The matching profile lane is named so the user knows which lane overlaps.
|
||||
expect(combined).toContain('profile "work"');
|
||||
|
||||
spy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not warn when no account-profile lane email matches', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
seedAccountLane(ccsDir, 'work', 'someone-else@example.com');
|
||||
|
||||
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
|
||||
loggedIn: false,
|
||||
email: null,
|
||||
authMethod: null,
|
||||
apiProvider: null,
|
||||
orgId: null,
|
||||
orgName: null,
|
||||
subscriptionType: null,
|
||||
});
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
|
||||
if (typeof msg === 'string') stderrOutput.push(msg);
|
||||
});
|
||||
|
||||
checkCrossLaneEmailOverlap('agy', 'mine@example.com');
|
||||
|
||||
expect(stderrOutput.join('\n')).not.toContain('cross-lane');
|
||||
|
||||
spy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('silently skips an account-profile lane with a missing/unreadable .claude.json', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
// Account profile exists but its lane has no .claude.json (email === null).
|
||||
seedAccountLane(ccsDir, 'broken', null);
|
||||
|
||||
const spy = spyOn(claudeDetector, 'getClaudeAuthStatus').mockReturnValue({
|
||||
loggedIn: false,
|
||||
email: null,
|
||||
authMethod: null,
|
||||
apiProvider: null,
|
||||
orgId: null,
|
||||
orgName: null,
|
||||
subscriptionType: null,
|
||||
});
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation((msg?: unknown) => {
|
||||
if (typeof msg === 'string') stderrOutput.push(msg);
|
||||
});
|
||||
|
||||
// Must not warn and must not throw on the unreadable lane.
|
||||
expect(() => checkCrossLaneEmailOverlap('agy', 'lane@example.com')).not.toThrow();
|
||||
expect(stderrOutput.join('\n')).not.toContain('cross-lane');
|
||||
|
||||
spy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── 15. Safety invariant: disablePoolRouting always restores cooling=true ─
|
||||
it('safety invariant: disablePoolRouting ALWAYS writes disable-cooling: true', async () => {
|
||||
const { enablePoolRouting, disablePoolRouting } = await import(
|
||||
@@ -1280,3 +1392,194 @@ describe('Phase 3: routing-subcommand pool-active warning regression', () => {
|
||||
affinitySpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PR #1514 review fixes ────────────────────────────────────────────────────
|
||||
// Legacy-config gate, automation bypass, enable rollback-on-regenerate-failure,
|
||||
// remote pool-state manageable flag, and apply-message pool override note.
|
||||
|
||||
describe('PR #1514: maybeOfferPoolRouting legacy-config and automation guards', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateSharedConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
delete process.env.CCS_YES;
|
||||
});
|
||||
|
||||
// Fix index 5: legacy profiles.json-only install (no config.yaml) must skip the
|
||||
// prompt before any prompting OR dismissal persistence, so we never implicitly
|
||||
// create config.yaml and silently flip isUnifiedMode().
|
||||
it('skips with legacy-config and does not create config.yaml (decline path not reached)', async () => {
|
||||
// Remove the config.yaml createTestHome wrote so hasUnifiedConfig() is false.
|
||||
const configYaml = path.join(tempHome, '.ccs', 'config.yaml');
|
||||
fs.rmSync(configYaml, { force: true });
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.skipReason).toBe('legacy-config');
|
||||
// Critical: the dismissal/accept paths (which write config.yaml) must NOT run.
|
||||
expect(fs.existsSync(configYaml)).toBe(false);
|
||||
});
|
||||
|
||||
// Fix index 9: --yes / CCS_YES must NOT auto-accept this instance-global consent.
|
||||
// It must skip WITHOUT printing the prompt and WITHOUT persisting dismissal.
|
||||
it('skips with automation-bypass under CCS_YES=1 without prompting or dismissing', async () => {
|
||||
process.env.CCS_YES = '1';
|
||||
|
||||
// 2 accounts so we are past the post-add count check and reach the TTY/bypass guards.
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const accountsPath = path.join(ccsDir, 'cliproxy', 'accounts.json');
|
||||
const authDir = path.join(ccsDir, 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(accountsPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
accountsPath,
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
claude: {
|
||||
default: 'a1',
|
||||
accounts: {
|
||||
a1: { email: 'a@b.com', tokenFile: 'a.json', nickname: 'a', createdAt: 0 },
|
||||
a2: { email: 'c@d.com', tokenFile: 'c.json', nickname: 'c', createdAt: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
fs.writeFileSync(path.join(authDir, 'a.json'), '{}', 'utf-8');
|
||||
fs.writeFileSync(path.join(authDir, 'c.json'), '{}', 'utf-8');
|
||||
|
||||
// Force TTY so only the automation guard (not the non-tty guard) can fire.
|
||||
const origStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
||||
const origStderrTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
// confirm must NEVER be called on the bypass path.
|
||||
const confirmSpy = spyOn(promptModule.InteractivePrompt, 'confirm').mockResolvedValue(true);
|
||||
|
||||
const result = await poolOptInModule.maybeOfferPoolRouting('claude', 1, 8317);
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.skipReason).toBe('automation-bypass');
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
// Dismissal must NOT be persisted (future interactive run should still offer).
|
||||
expect(poolOptInModule.isPoolPromptDismissed()).toBe(false);
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
if (origStdinTTY) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', origStdinTTY);
|
||||
} else {
|
||||
delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
if (origStderrTTY) {
|
||||
Object.defineProperty(process.stderr, 'isTTY', origStderrTTY);
|
||||
} else {
|
||||
delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR #1514: enablePoolRouting rollback on regenerate failure', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
invalidateSharedConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Fix index 6: if regenerateConfig throws, the pool_routing.enabled flag must be
|
||||
// rolled back so status surfaces do not lie, and a recovery message is returned.
|
||||
// Force a real fs failure (no mock): place a regular FILE where the config
|
||||
// directory must be, so regenerateConfig's mkdirSync(dirname) throws ENOTDIR.
|
||||
it('rolls back enabled flag and returns failure recovery copy when regenerate throws', async () => {
|
||||
const ts = Date.now();
|
||||
const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514fail=${ts}`);
|
||||
const { loadOrCreateUnifiedConfig } = await import(
|
||||
`../../config/config-loader-facade?p1514fail=${ts}`
|
||||
);
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
// Block the config dir: create a FILE named "blocked" then aim the config path
|
||||
// at blocked/config.yaml so mkdirSync(dirname) hits ENOTDIR.
|
||||
const blockedFile = path.join(cliproxyDir, 'blocked');
|
||||
fs.writeFileSync(blockedFile, 'not a dir', 'utf-8');
|
||||
const configPath = path.join(blockedFile, 'config.yaml');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
|
||||
const result = enablePoolRouting(8317, { configPath, authDir });
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.message).toContain('[X] Could not write CLIProxy config');
|
||||
expect(result.message).toContain('ccs cliproxy pool --enable');
|
||||
|
||||
// The flag must be rolled back to NOT-enabled so status surfaces do not lie.
|
||||
const cfg = loadOrCreateUnifiedConfig();
|
||||
expect(cfg.cliproxy?.pool_routing?.enabled).not.toBe(true);
|
||||
});
|
||||
|
||||
// Repair path: when the flag is already true (a prior regenerate failed),
|
||||
// pool --enable must re-run regenerateConfig instead of a dead no-op.
|
||||
it('already-enabled path re-runs regenerateConfig (idempotent repair)', async () => {
|
||||
const ts = Date.now();
|
||||
// Persist enabled=true WITHOUT a regenerated config.yaml (simulate prior failure).
|
||||
const { mutateConfig, invalidateConfigCache } = await import(
|
||||
`../../config/config-loader-facade?p1514repair=${ts}`
|
||||
);
|
||||
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
|
||||
cfg.cliproxy = cfg.cliproxy ?? {};
|
||||
cfg.cliproxy.pool_routing = { enabled: true, max_retry_credentials: 3 };
|
||||
});
|
||||
invalidateConfigCache();
|
||||
invalidateSharedConfigCache();
|
||||
|
||||
const { enablePoolRouting } = await import(`../routing/routing-strategy?p1514repair=${ts}`);
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const configPath = path.join(ccsDir, 'cliproxy', 'config.yaml');
|
||||
const authDir = path.join(ccsDir, 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
// Config does NOT exist yet — the repair must create it.
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
|
||||
const result = enablePoolRouting(8317, { configPath, authDir });
|
||||
|
||||
// Idempotent (changed=false) but the config was regenerated with pool rails.
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.failed).toBeFalsy();
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('disable-cooling: false');
|
||||
expect(content).toContain('strategy: fill-first');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -721,3 +721,215 @@ describe('resolveEffectiveDrainOrder', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,13 @@ async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Pro
|
||||
}
|
||||
}
|
||||
|
||||
const SETTINGS_ON: PoolRoutingSettings = {
|
||||
poolEnabled: true,
|
||||
strategy: 'fill-first',
|
||||
sessionAffinityEnabled: true,
|
||||
sessionAffinityTtl: '1h',
|
||||
};
|
||||
|
||||
async function loadPoolState() {
|
||||
return import(`../pool-state?pool-state=${Date.now()}`);
|
||||
}
|
||||
@@ -260,3 +267,157 @@ describe('resolvePoolState - drain order', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,30 +8,107 @@
|
||||
* Google/Anthropic treat as suspicious.
|
||||
*
|
||||
* Scope of check: compare the newly registered CLIProxy account email against
|
||||
* the email of the currently active native Claude Code profile (via `claude
|
||||
* auth status`). The profiles.json v3.0 schema removed the email field,
|
||||
* so we rely on the live auth status command which is already used by
|
||||
* quota-fetcher-claude.ts.
|
||||
* 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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the newly added CLIProxy account email matches the email of
|
||||
* the currently active native Claude Code profile.
|
||||
* Read the OAuth email stored in a native Claude lane's `.claude.json`.
|
||||
*
|
||||
* Emits a warning to stderr if an overlap is detected. Silent on errors
|
||||
* (CLI not found, not logged in, etc.) — the check is best-effort.
|
||||
* 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
|
||||
@@ -40,21 +117,36 @@ export function checkCrossLaneEmailOverlap(provider: CLIProxyProvider, email: st
|
||||
if (!CROSS_LANE_RISK_PROVIDERS.includes(provider)) return;
|
||||
|
||||
try {
|
||||
const status = getClaudeAuthStatus();
|
||||
if (!status?.loggedIn || !status.email) return;
|
||||
|
||||
const normalized = email.toLowerCase().trim();
|
||||
const nativeNormalized = status.email.toLowerCase().trim();
|
||||
if (normalized.length === 0) return;
|
||||
|
||||
if (normalized !== nativeNormalized) 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);
|
||||
const nativeMasked = maskEmail(status.email);
|
||||
|
||||
console.error('');
|
||||
console.error(warn(`Account safety: cross-lane email overlap detected for ${provider}`));
|
||||
console.error(` CLIProxy account: ${masked} (${provider})`);
|
||||
console.error(` Native Claude Code profile: ${nativeMasked} (logged in)`);
|
||||
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.'
|
||||
);
|
||||
|
||||
@@ -29,9 +29,30 @@ 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.
|
||||
@@ -258,6 +279,29 @@ export async function writeAuthFilePriorityViaApi(
|
||||
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' });
|
||||
@@ -283,6 +327,105 @@ export async function writeAuthFilePriorityViaApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -496,3 +639,76 @@ export function resolveEffectiveDrainOrder(
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
* 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.
|
||||
@@ -23,10 +27,18 @@
|
||||
* both exist, because that is the routing truth across processes.
|
||||
*/
|
||||
|
||||
import { resolveEffectiveDrainOrder, type DrainOrderInput } from './drain-order';
|
||||
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';
|
||||
|
||||
@@ -54,9 +66,11 @@ export interface PoolAccountState {
|
||||
cooldownUntil?: number;
|
||||
/**
|
||||
* For state 'cooling': where the cooldown reading came from.
|
||||
* 'persisted' = quota-paused.json (cross-process), 'memory' = in-process map.
|
||||
* 'proxy' = live in-proxy 429 cooldown (GET /v0/management/auth-files),
|
||||
* 'persisted' = quota-paused.json (cross-process),
|
||||
* 'memory' = in-process quota-manager map.
|
||||
*/
|
||||
cooldownSource?: 'persisted' | 'memory';
|
||||
cooldownSource?: 'proxy' | 'persisted' | 'memory';
|
||||
/** ISO timestamp the account was paused (manual pause), when state is 'paused'. */
|
||||
pausedAt?: string;
|
||||
}
|
||||
@@ -103,23 +117,34 @@ export interface ResolvePoolStateInput {
|
||||
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 persisted quota cooldown whose pausedAt matches the account's pausedAt is
|
||||
* 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).
|
||||
* 2. A paused account without a matching cooldown record is a manual/safety pause
|
||||
* 3. A paused account without a matching cooldown record is a manual/safety pause
|
||||
* -> 'paused'.
|
||||
* 3. An unpaused account with an in-memory cooldown still in effect -> 'cooling'.
|
||||
* 4. Otherwise -> 'available'.
|
||||
* 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 = {
|
||||
@@ -130,6 +155,22 @@ function classifyAccount(
|
||||
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) {
|
||||
@@ -194,6 +235,10 @@ function resolveDrainOrder(
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -207,8 +252,14 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
||||
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, now)
|
||||
classifyAccount(provider, account, cooldownByAccount, proxyCooldownByTokenFile, now)
|
||||
);
|
||||
|
||||
// Drain order is computed over accounts that are in rotation (not paused).
|
||||
@@ -222,3 +273,44 @@ export function resolvePoolState(input: ResolvePoolStateInput): PoolState {
|
||||
|
||||
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 * 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 } from '../env-builder';
|
||||
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder';
|
||||
import { clearConfigCache } from '../base-config-loader';
|
||||
|
||||
const MODEL_KEYS = [
|
||||
@@ -380,4 +380,174 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -590,6 +590,26 @@ function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
|
||||
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
|
||||
@@ -783,6 +803,17 @@ 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. Priority 1 (explicit custom
|
||||
// settings path) is intentionally left untouched: an explicitly passed
|
||||
// settings file is the user's deliberate choice.
|
||||
if (provider === 'claude') {
|
||||
userEnvVars = filterClaudeStaleModelPins(userEnvVars);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to base config
|
||||
|
||||
@@ -230,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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHi
|
||||
|
||||
console.log(
|
||||
info(
|
||||
`You have ${count} Claude profiles. 'ccs claude' pool auto-continues on limits (accounts share quota). Docs: ${POOL_DOCS_LINK}`
|
||||
`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}`
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
POOL_ROUTING_VERIFIED_PROVIDERS,
|
||||
POOL_MAX_RETRY_CREDENTIALS,
|
||||
} from './routing-strategy';
|
||||
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
|
||||
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';
|
||||
@@ -63,6 +67,10 @@ export function isPoolPromptDismissed(): boolean {
|
||||
* 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 = {
|
||||
@@ -134,6 +142,23 @@ export async function maybeOfferPoolRouting(
|
||||
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.
|
||||
if (!hasUnifiedConfig()) {
|
||||
console.log(
|
||||
info(
|
||||
'[i] Multiple accounts detected, but pool routing needs the unified config. ' +
|
||||
"Run 'ccs migrate' first, then 'ccs cliproxy pool --enable'."
|
||||
)
|
||||
);
|
||||
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 {
|
||||
@@ -178,6 +203,19 @@ export async function maybeOfferPoolRouting(
|
||||
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 =
|
||||
@@ -226,6 +264,14 @@ export async function maybeOfferPoolRouting(
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -46,16 +46,43 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -145,7 +172,24 @@ export function enablePoolRouting(
|
||||
): 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,
|
||||
@@ -185,9 +229,26 @@ export function enablePoolRouting(
|
||||
};
|
||||
});
|
||||
|
||||
const configPath = options.configPath ?? getConfigPathForPort(port);
|
||||
const authDir = options.authDir ?? getAuthDir();
|
||||
regenerateConfig(port, { configPath, authDir });
|
||||
// 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,
|
||||
@@ -218,6 +279,8 @@ export function disablePoolRouting(
|
||||
): 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,
|
||||
@@ -236,9 +299,19 @@ export function disablePoolRouting(
|
||||
};
|
||||
});
|
||||
|
||||
const configPath = options.configPath ?? getConfigPathForPort(port);
|
||||
const authDir = options.authDir ?? getAuthDir();
|
||||
regenerateConfig(port, { configPath, authDir });
|
||||
// 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,
|
||||
@@ -262,6 +335,16 @@ const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
|
||||
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 {
|
||||
@@ -270,7 +353,11 @@ export interface CliproxyRoutingState {
|
||||
target: 'local' | 'remote';
|
||||
reachable: boolean;
|
||||
message?: string;
|
||||
/** Pool routing mode (proxy-wide). Present for both local and remote targets. */
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -417,12 +504,24 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
|
||||
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,
|
||||
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.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -497,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 };
|
||||
@@ -512,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 {
|
||||
@@ -521,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -552,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 = {
|
||||
@@ -572,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@
|
||||
* account states must be NAMED differently because they map to different client
|
||||
* failure modes), reset formatting, and drain-order mode labels.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { 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';
|
||||
|
||||
@@ -30,7 +33,10 @@ describe('describeAccountState', () => {
|
||||
|
||||
expect(available.label).toBe('available');
|
||||
expect(available.tone).toBe('available');
|
||||
expect(paused.label).toContain('paused');
|
||||
// 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');
|
||||
@@ -78,3 +84,141 @@ describe('modeLabel', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
computeTierDrainOrder,
|
||||
applyDrainOrder,
|
||||
resolveEffectiveDrainOrder,
|
||||
readAuthFilePriority,
|
||||
clearDrainOrderPriorities,
|
||||
tieBreakKey,
|
||||
type DrainOrderEntry,
|
||||
type DrainOrderInput,
|
||||
@@ -122,9 +122,11 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
|
||||
const effective = resolveEffectiveDrainOrder(provider, accounts);
|
||||
|
||||
if (effective.mode === 'file') {
|
||||
// File order: no priority writes; show stable file order. Reached when no
|
||||
// config is stored, or when a stored manual order has only stale IDs.
|
||||
printFileOrderView(provider, accounts);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -160,20 +162,25 @@ async function handleOrderShow(provider: CLIProxyProvider): Promise<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function readFilePriorityNote(tokenFile: string): string {
|
||||
try {
|
||||
const p = readAuthFilePriority(tokenFile);
|
||||
return p !== undefined ? dim(` [priority: ${p}]`) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
function filePriorityNote(entry: DrainOrderEntry): string {
|
||||
return entry.currentPriority !== undefined ? dim(` [priority: ${entry.currentPriority}]`) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stable file-order view (no priority writes). Used when no drain
|
||||
* order config is stored, or when a stored manual order has only stale IDs.
|
||||
* 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, accounts: DrainOrderInput[]): void {
|
||||
function printFileOrderView(
|
||||
provider: CLIProxyProvider,
|
||||
entries: DrainOrderEntry[],
|
||||
hasDrift: boolean
|
||||
): void {
|
||||
if (!TIER_AWARE_PROVIDERS.has(provider)) {
|
||||
console.log(
|
||||
info(
|
||||
@@ -182,17 +189,27 @@ function printFileOrderView(provider: CLIProxyProvider, accounts: DrainOrderInpu
|
||||
)
|
||||
);
|
||||
}
|
||||
console.log(` Mode: ${dim('file order (no priority set)')}`);
|
||||
// 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 (file order):'));
|
||||
// Sort by tokenFile ascending to match the Go selector's stable order.
|
||||
// Upstream sorts by Auth.ID byte order and only lowercases on Windows (see tieBreakKey).
|
||||
const sortedByFile = accounts
|
||||
.slice()
|
||||
.sort((a, b) => (tieBreakKey(a.tokenFile) < tieBreakKey(b.tokenFile) ? -1 : 1));
|
||||
sortedByFile.forEach((a, idx) => {
|
||||
const pri = readFilePriorityNote(a.tokenFile);
|
||||
console.log(` ${String(idx + 1).padStart(3)} ${color(a.accountId, 'command')}${pri}`);
|
||||
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(
|
||||
@@ -463,21 +480,114 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset drain order to file order (removes persisted config, no priority writes).
|
||||
* 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 cleared = clearDrainOrderConfig(provider);
|
||||
if (cleared) {
|
||||
console.log(ok(`Drain order reset to file order for ${provider}.`));
|
||||
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(
|
||||
dim(
|
||||
' Note: existing priority values in auth files are not removed.\n' +
|
||||
' CLIProxy will continue using them until they are overwritten or files are re-created.'
|
||||
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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
|
||||
import { subheader, color, dim, warn } from '../../utils/ui';
|
||||
import {
|
||||
resolvePoolState,
|
||||
resolvePoolStateWithProxyCooldowns,
|
||||
hasProxySourcedCooling,
|
||||
POOL_TIER_AWARE_PROVIDERS,
|
||||
type PoolAccountState,
|
||||
type PoolRoutingSettings,
|
||||
type PoolDrainOrderMode,
|
||||
type PoolState,
|
||||
} from '../../cliproxy/accounts/pool-state';
|
||||
import {
|
||||
getConfiguredCliproxyRoutingStrategy,
|
||||
@@ -95,7 +97,11 @@ export function describeAccountState(
|
||||
return { label: `cooling until ${reset}${src}`, tone: 'cooling' };
|
||||
}
|
||||
case 'paused':
|
||||
return { label: 'paused (manual)', tone: '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' };
|
||||
@@ -114,16 +120,83 @@ export function modeLabel(mode: PoolDrainOrderMode): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 function renderProviderPoolSection(
|
||||
export async function renderProviderPoolSection(
|
||||
provider: CLIProxyProvider,
|
||||
settings: PoolRoutingSettings,
|
||||
now: number = Date.now()
|
||||
): void {
|
||||
const pool = resolvePoolState({ provider, settings, now });
|
||||
): Promise<void> {
|
||||
const pool = await resolvePoolStateWithProxyCooldowns({ provider, settings, now });
|
||||
|
||||
if (pool.states.length === 0) {
|
||||
return;
|
||||
@@ -155,16 +228,7 @@ export function renderProviderPoolSection(
|
||||
console.log(` Order: ${dim(orderMode)} ${dim('(no active accounts)')}`);
|
||||
}
|
||||
|
||||
// Drift: stored config order diverges from what is on disk. Re-auth drops the
|
||||
// priority attribute and --reset leaves residuals, so warn instead of showing
|
||||
// a "next account" that silently disagrees with the selector.
|
||||
if (pool.drainOrder.hasDrift) {
|
||||
console.log(
|
||||
` ${warn(
|
||||
`Drift: stored order does not match auth files; run "ccs cliproxy accounts order ${provider}" to inspect, then re-apply with --set/--by-tier.`
|
||||
)}`
|
||||
);
|
||||
}
|
||||
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).
|
||||
@@ -188,16 +252,13 @@ export function renderProviderPoolSection(
|
||||
console.log(` - ${account.accountId}${defaultMark}: ${rendered}`);
|
||||
}
|
||||
|
||||
// Honest note when cooling is impossible to observe (cooling flip off).
|
||||
if (!settings.poolEnabled) {
|
||||
const anyCooling = pool.states.some((s) => s.state === 'cooling');
|
||||
if (!anyCooling) {
|
||||
console.log(
|
||||
dim(
|
||||
' Note: cooling is off (stock stability mode); accounts never show a quota cooldown here.'
|
||||
)
|
||||
);
|
||||
}
|
||||
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:"
|
||||
|
||||
@@ -8,25 +8,77 @@
|
||||
*/
|
||||
|
||||
import { initUI, header, ok, warn, info } from '../../utils/ui';
|
||||
import { enablePoolRouting, disablePoolRouting } from '../../cliproxy/routing/routing-strategy';
|
||||
import {
|
||||
enablePoolRouting,
|
||||
disablePoolRouting,
|
||||
POOL_MAX_RETRY_CREDENTIALS,
|
||||
} from '../../cliproxy/routing/routing-strategy';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
||||
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 port = CLIPROXY_DEFAULT_PORT;
|
||||
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 (hasAnyFlag(args, ['--enable'])) {
|
||||
if (wantsEnable) {
|
||||
const result = enablePoolRouting(port, { configPath, authDir });
|
||||
if (result.changed) {
|
||||
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));
|
||||
@@ -35,9 +87,12 @@ export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAnyFlag(args, ['--disable'])) {
|
||||
if (wantsDisable) {
|
||||
const result = disablePoolRouting(port, { configPath, authDir });
|
||||
if (result.changed) {
|
||||
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));
|
||||
|
||||
@@ -907,7 +907,8 @@ export async function handleQuotaStatus(
|
||||
runtime.render(result);
|
||||
// Pool context: drain order + per-account state (available/cooling/paused).
|
||||
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
|
||||
renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
|
||||
// Async: folds in live in-proxy 429 cooldowns when pool routing is on.
|
||||
await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ export function RoutingGuidanceCard({
|
||||
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;
|
||||
@@ -174,26 +180,45 @@ 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">
|
||||
{poolEnabled && poolMaxRetry !== undefined
|
||||
? t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })
|
||||
: t('routingGuidance.drainOrderHint')}
|
||||
{poolLocalOnly
|
||||
? poolLocalOnlyMessage
|
||||
: poolEnabled
|
||||
? t('routingGuidance.drainOrderHint')
|
||||
: t('routingGuidance.poolRoutingOffHint')}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={poolEnabled ? 'secondary' : 'outline'}
|
||||
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
|
||||
title={
|
||||
poolEnabled
|
||||
? t('routingGuidance.poolRoutingManaged')
|
||||
: t('routingGuidance.poolRoutingOffHint')
|
||||
poolLocalOnly
|
||||
? poolLocalOnlyMessage
|
||||
: poolEnabled
|
||||
? t('routingGuidance.poolRoutingManaged')
|
||||
: t('routingGuidance.poolRoutingOffHint')
|
||||
}
|
||||
>
|
||||
{poolEnabled ? t('routingGuidance.poolRoutingOn') : t('routingGuidance.poolRoutingOff')}
|
||||
{poolLocalOnly
|
||||
? t('routingGuidance.localOnly')
|
||||
: poolEnabled
|
||||
? `${t('routingGuidance.poolRoutingOn')} · ${t('routingGuidance.poolMaxRetry', {
|
||||
count: poolMaxRetry ?? 0,
|
||||
})}`
|
||||
: t('routingGuidance.poolRoutingOff')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -267,18 +292,22 @@ export function RoutingGuidanceCard({
|
||||
{state ? <Badge variant="outline">{state.target}</Badge> : null}
|
||||
{state ? (
|
||||
<Badge
|
||||
variant={poolEnabled ? 'secondary' : 'outline'}
|
||||
variant={poolLocalOnly ? 'outline' : poolEnabled ? 'secondary' : 'outline'}
|
||||
title={
|
||||
poolEnabled
|
||||
? t('routingGuidance.poolRoutingManaged')
|
||||
: t('routingGuidance.poolRoutingOffHint')
|
||||
poolLocalOnly
|
||||
? poolLocalOnlyMessage
|
||||
: poolEnabled
|
||||
? t('routingGuidance.poolRoutingManaged')
|
||||
: t('routingGuidance.poolRoutingOffHint')
|
||||
}
|
||||
>
|
||||
{t('routingGuidance.poolRouting')}:{' '}
|
||||
{poolEnabled
|
||||
? t('routingGuidance.poolRoutingOn')
|
||||
: t('routingGuidance.poolRoutingOff')}
|
||||
{poolEnabled && poolMaxRetry !== undefined
|
||||
{poolLocalOnly
|
||||
? t('routingGuidance.localOnly')
|
||||
: poolEnabled
|
||||
? t('routingGuidance.poolRoutingOn')
|
||||
: t('routingGuidance.poolRoutingOff')}
|
||||
{!poolLocalOnly && poolEnabled && poolMaxRetry !== undefined
|
||||
? ` · ${t('routingGuidance.poolMaxRetry', { count: poolMaxRetry })}`
|
||||
: ''}
|
||||
</Badge>
|
||||
@@ -339,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">
|
||||
|
||||
@@ -512,6 +512,14 @@ 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 {
|
||||
|
||||
@@ -1924,6 +1924,10 @@ const resources = {
|
||||
'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>.',
|
||||
@@ -4559,6 +4563,9 @@ const resources = {
|
||||
'账号池路由已开启:CCS 为整个代理统一管理策略(优先填满)、会话粘性和冷却。',
|
||||
poolRoutingOffHint:
|
||||
'账号池路由已关闭。可在命令行启用(ccs cliproxy pool --enable)以使用带冷却的优先填满。',
|
||||
poolRoutingApplyWarning:
|
||||
'账号池路由已开启。在禁用之前,策略和会话粘性的更改不会生效:ccs cliproxy pool --disable',
|
||||
poolRoutingLocalOnly: '账号池路由仅在本地管理;此远程代理可能不会反映该设置。',
|
||||
poolMaxRetry: '最大重试 {{count}}',
|
||||
drainOrderHint:
|
||||
'查看每个账号的排空顺序和冷却状态:运行 ccs cliproxy quota 或 ccs cliproxy accounts order <provider>。',
|
||||
@@ -7257,6 +7264,10 @@ const resources = {
|
||||
'Đị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>.',
|
||||
@@ -10432,6 +10443,10 @@ const resources = {
|
||||
'プールルーティングが有効です。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> を実行してください。',
|
||||
@@ -12704,6 +12719,10 @@ const resources = {
|
||||
'풀 라우팅이 켜져 있습니다. 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>를 실행하세요.',
|
||||
|
||||
@@ -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