mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
Merge pull request #1730 from kaitranntt/kai/fix-cliproxy-backend-version-gates
fix(cliproxy): backend version compatibility gates and ARM assets (#1724, #1725, #1726)
This commit is contained in:
13 files changed
+535
-43
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"syncFs": {
|
||||
"totalOccurrences": 2509,
|
||||
"totalOccurrences": 2513,
|
||||
"filesAffected": 263,
|
||||
"hotpathOccurrences": 1183,
|
||||
"hotpathFilesAffected": 156,
|
||||
|
||||
@@ -6,7 +6,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Sync fs occurrences (all) | 2509 |
|
||||
| Sync fs occurrences (all) | 2513 |
|
||||
| Sync fs files affected (all) | 263 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1183 |
|
||||
| Sync fs files affected (runtime hotpaths) | 156 |
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
"src/channels/official-channels-store.ts:291",
|
||||
"src/channels/official-channels-store.ts:296",
|
||||
"src/channels/official-channels-store.ts:299",
|
||||
"src/cliproxy/accounts/drain-order.ts:131",
|
||||
"src/cliproxy/accounts/drain-order.ts:139",
|
||||
"src/cliproxy/accounts/drain-order.ts:246",
|
||||
"src/cliproxy/accounts/drain-order.ts:253",
|
||||
"src/cliproxy/accounts/drain-order.ts:279",
|
||||
"src/cliproxy/accounts/drain-order.ts:342",
|
||||
"src/cliproxy/accounts/drain-order.ts:149",
|
||||
"src/cliproxy/accounts/drain-order.ts:157",
|
||||
"src/cliproxy/accounts/drain-order.ts:264",
|
||||
"src/cliproxy/accounts/drain-order.ts:271",
|
||||
"src/cliproxy/accounts/drain-order.ts:297",
|
||||
"src/cliproxy/accounts/drain-order.ts:360",
|
||||
"src/cliproxy/accounts/registry.ts:521",
|
||||
"src/cliproxy/accounts/registry.ts:536",
|
||||
"src/cliproxy/accounts/registry.ts:544",
|
||||
@@ -38,10 +38,10 @@
|
||||
"src/cliproxy/binary/installer.ts:74",
|
||||
"src/cliproxy/binary/installer.ts:88",
|
||||
"src/cliproxy/binary/lifecycle.ts:137",
|
||||
"src/cliproxy/binary/platform-detector.ts:153",
|
||||
"src/cliproxy/binary/platform-detector.ts:160",
|
||||
"src/cliproxy/binary/platform-detector.ts:152",
|
||||
"src/cliproxy/binary/platform-detector.ts:159",
|
||||
"src/cliproxy/binary/verifier.ts:55",
|
||||
"src/cliproxy/binary/version-checker.ts:108",
|
||||
"src/cliproxy/binary/version-checker.ts:118",
|
||||
"src/cliproxy/config/base-config-loader.ts:54",
|
||||
"src/cliproxy/config/base-config-loader.ts:66",
|
||||
"src/cliproxy/config/base-config-loader.ts:81",
|
||||
@@ -59,9 +59,9 @@
|
||||
"src/cliproxy/executor/proxy-resolver.ts:177",
|
||||
"src/cliproxy/executor/session-bridge.ts:147",
|
||||
"src/cliproxy/proxy/https-tunnel-proxy.ts:61",
|
||||
"src/cliproxy/routing/routing-strategy.ts:480",
|
||||
"src/cliproxy/routing/routing-strategy.ts:488",
|
||||
"src/cliproxy/routing/routing-strategy.ts:708",
|
||||
"src/cliproxy/routing/routing-strategy.ts:490",
|
||||
"src/cliproxy/routing/routing-strategy.ts:498",
|
||||
"src/cliproxy/routing/routing-strategy.ts:718",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:139",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:150",
|
||||
"src/cliproxy/services/remote-auth-fetcher.ts:152",
|
||||
|
||||
@@ -933,3 +933,36 @@ describe('fetchProxyAuthCooldowns', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DRAIN_ORDER_MIN_VERSION and isDrainOrderSupported (#1724)', () => {
|
||||
it('defines backend-specific minimum versions for drain-order priorities', async () => {
|
||||
const { DRAIN_ORDER_MIN_VERSION } = await loadDrainOrder();
|
||||
expect(DRAIN_ORDER_MIN_VERSION).toEqual({
|
||||
original: '6.6.106',
|
||||
plus: '6.6.107-0',
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluates original backend boundary correctly', async () => {
|
||||
const { isDrainOrderSupported } = await loadDrainOrder();
|
||||
expect(isDrainOrderSupported('original', '6.6.105')).toBe(false);
|
||||
expect(isDrainOrderSupported('original', '6.6.106')).toBe(true);
|
||||
expect(isDrainOrderSupported('original', '6.7.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates plus backend boundary with fork suffixes correctly', async () => {
|
||||
const { isDrainOrderSupported } = await loadDrainOrder();
|
||||
expect(isDrainOrderSupported('plus', '6.6.105-9')).toBe(false);
|
||||
expect(isDrainOrderSupported('plus', '6.6.106-0')).toBe(false);
|
||||
expect(isDrainOrderSupported('plus', '6.6.107-0')).toBe(true);
|
||||
expect(isDrainOrderSupported('plus', '7.2.127-7')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid and empty version strings', async () => {
|
||||
const { isDrainOrderSupported } = await loadDrainOrder();
|
||||
expect(isDrainOrderSupported('original', '')).toBe(false);
|
||||
expect(isDrainOrderSupported('original', 'invalid')).toBe(false);
|
||||
expect(isDrainOrderSupported('plus', '')).toBe(false);
|
||||
expect(isDrainOrderSupported('plus', 'invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,29 @@ import {
|
||||
} from '../proxy/proxy-target-resolver';
|
||||
import { loadDrainOrderConfig } from './registry';
|
||||
import type { AccountTier } from './types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import type { CLIProxyBackend, CLIProxyProvider } from '../types';
|
||||
import {
|
||||
type CLIProxyBackendMinVersions,
|
||||
meetsBackendMinimumVersion,
|
||||
} from '../binary/version-checker';
|
||||
|
||||
/** Minimum valid priority value. Management layer treats 0 as delete. */
|
||||
export const MIN_PRIORITY = 1;
|
||||
|
||||
/**
|
||||
* Minimum CLIProxy version that supports drain-order priority selection.
|
||||
* Original: v6.6.106
|
||||
* Plus: v6.6.107-0
|
||||
*/
|
||||
export const DRAIN_ORDER_MIN_VERSION: CLIProxyBackendMinVersions = {
|
||||
original: '6.6.106',
|
||||
plus: '6.6.107-0',
|
||||
};
|
||||
|
||||
export function isDrainOrderSupported(backend: CLIProxyBackend, installedVersion: string): boolean {
|
||||
return meetsBackendMinimumVersion(installedVersion, backend, DRAIN_ORDER_MIN_VERSION);
|
||||
}
|
||||
|
||||
/** Management API path for patching auth file fields */
|
||||
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { compareCliproxyVersions, isNewerVersion } from '../version-checker';
|
||||
import {
|
||||
type CLIProxyBackendMinVersions,
|
||||
compareCliproxyVersions,
|
||||
isNewerVersion,
|
||||
meetsBackendMinimumVersion,
|
||||
} from '../version-checker';
|
||||
|
||||
describe('cliproxy version comparison', () => {
|
||||
it('treats missing fork release suffix as zero', () => {
|
||||
@@ -19,3 +24,38 @@ describe('cliproxy version comparison', () => {
|
||||
expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meetsBackendMinimumVersion', () => {
|
||||
const testMinimums: CLIProxyBackendMinVersions = {
|
||||
original: '7.2.104',
|
||||
plus: '7.2.105-0',
|
||||
};
|
||||
|
||||
it('evaluates original backend boundary correctly', () => {
|
||||
expect(meetsBackendMinimumVersion('7.2.103', 'original', testMinimums)).toBe(false);
|
||||
expect(meetsBackendMinimumVersion('7.2.104', 'original', testMinimums)).toBe(true);
|
||||
expect(meetsBackendMinimumVersion('7.2.105', 'original', testMinimums)).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates plus backend boundary with fork suffixes correctly', () => {
|
||||
expect(meetsBackendMinimumVersion('7.2.104-9', 'plus', testMinimums)).toBe(false);
|
||||
expect(meetsBackendMinimumVersion('7.2.105-0', 'plus', testMinimums)).toBe(true);
|
||||
expect(meetsBackendMinimumVersion('7.2.105-1', 'plus', testMinimums)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats missing fork suffix as equal to -0', () => {
|
||||
const minWithSuffix: CLIProxyBackendMinVersions = {
|
||||
original: '6.8.34-0',
|
||||
plus: '6.8.34-0',
|
||||
};
|
||||
expect(meetsBackendMinimumVersion('6.8.34', 'original', minWithSuffix)).toBe(true);
|
||||
expect(meetsBackendMinimumVersion('6.8.34', 'plus', minWithSuffix)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty and invalid version strings against real minimums', () => {
|
||||
expect(meetsBackendMinimumVersion('', 'original', testMinimums)).toBe(false);
|
||||
expect(meetsBackendMinimumVersion('not-a-version', 'original', testMinimums)).toBe(false);
|
||||
expect(meetsBackendMinimumVersion('', 'plus', testMinimums)).toBe(false);
|
||||
expect(meetsBackendMinimumVersion('not-a-version', 'plus', testMinimums)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,10 @@ const RELEASE_ARCH_MAP: Record<SupportedArch, SupportedArch> = {
|
||||
};
|
||||
|
||||
const PLUS_NO_PLUGIN_ASSET_MIN_VERSION = '7.1.68-0';
|
||||
const PLUS_AARCH64_ASSET_MIN_VERSION = '7.1.45-1';
|
||||
const AARCH64_ASSET_MIN_VERSION: Readonly<Record<CLIProxyBackend, string>> = {
|
||||
original: '6.9.48',
|
||||
plus: '6.10.0-0',
|
||||
};
|
||||
|
||||
export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined {
|
||||
const arch = ARCH_MAP[nodeArch];
|
||||
@@ -121,11 +124,7 @@ function getReleaseArchForBackend(
|
||||
publicArch: SupportedArch,
|
||||
releaseArch: SupportedArch
|
||||
): SupportedArch {
|
||||
if (
|
||||
backend === 'plus' &&
|
||||
publicArch === 'arm64' &&
|
||||
!isAtLeastVersion(version, PLUS_AARCH64_ASSET_MIN_VERSION)
|
||||
) {
|
||||
if (publicArch === 'arm64' && !isAtLeastVersion(version, AARCH64_ASSET_MIN_VERSION[backend])) {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from '../binary/platform-detector';
|
||||
import type { CLIProxyBackend } from '../types';
|
||||
|
||||
export type CLIProxyBackendMinVersions = Readonly<Record<CLIProxyBackend, string>>;
|
||||
|
||||
interface FetchLatestVersionDeps {
|
||||
fetchJsonFn?: typeof fetchJson;
|
||||
}
|
||||
@@ -78,6 +80,14 @@ export function isNewerVersion(latest: string, current: string): boolean {
|
||||
return compareCliproxyVersions(latest, current) > 0;
|
||||
}
|
||||
|
||||
export function meetsBackendMinimumVersion(
|
||||
installedVersion: string,
|
||||
backend: CLIProxyBackend,
|
||||
minimumByBackend: CLIProxyBackendMinVersions
|
||||
): boolean {
|
||||
return compareCliproxyVersions(installedVersion, minimumByBackend[backend]) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version is within the faulty range (v81-85)
|
||||
* @returns true if version has known critical bugs
|
||||
|
||||
@@ -86,13 +86,102 @@ describe('Backend Selection', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('generates correct binary name for original backend', () => {
|
||||
const info = platformDetector.detectPlatform('6.6.51', 'original');
|
||||
assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_'));
|
||||
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
|
||||
it('selects arm64 vs aarch64 release assets across original and plus boundaries', () => {
|
||||
// Original pre-rename boundaries (<= 6.9.47) use arm64
|
||||
for (const version of [undefined, '6.9.45', '6.9.47']) {
|
||||
const effectiveVersion =
|
||||
version || platformDetector.BACKEND_CONFIG.original.fallbackVersion;
|
||||
withMockedProcessPlatform('darwin', 'arm64', () => {
|
||||
const info = platformDetector.detectPlatform(version, 'original');
|
||||
assert.strictEqual(info.arch, 'arm64');
|
||||
assert.strictEqual(
|
||||
info.binaryName,
|
||||
`CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz`
|
||||
);
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl(version, 'original'),
|
||||
`https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz`
|
||||
);
|
||||
});
|
||||
|
||||
withMockedProcessPlatform('linux', 'arm64', () => {
|
||||
const info = platformDetector.detectPlatform(version, 'original');
|
||||
assert.strictEqual(info.arch, 'arm64');
|
||||
assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz`);
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl(version, 'original'),
|
||||
`https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz`
|
||||
);
|
||||
});
|
||||
|
||||
withMockedProcessPlatform('win32', 'arm64', () => {
|
||||
const info = platformDetector.detectPlatform(version, 'original');
|
||||
assert.strictEqual(info.arch, 'arm64');
|
||||
assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_windows_arm64.zip`);
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl(version, 'original'),
|
||||
`https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_windows_arm64.zip`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Original post-rename boundary (>= 6.9.48) uses aarch64 while public arch remains arm64
|
||||
withMockedProcessPlatform('darwin', 'arm64', () => {
|
||||
const info = platformDetector.detectPlatform('6.9.48', 'original');
|
||||
assert.strictEqual(info.arch, 'arm64');
|
||||
assert.strictEqual(info.binaryName, 'CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz');
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl('6.9.48', 'original'),
|
||||
'https://github.com/router-for-me/CLIProxyAPI/releases/download/v6.9.48/CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz'
|
||||
);
|
||||
});
|
||||
|
||||
// Plus fallback (undefined) and explicit pre-rename (< 6.10.0-0) use arm64
|
||||
for (const version of [undefined, '6.9.45-0']) {
|
||||
const effectiveVersion = version || platformDetector.BACKEND_CONFIG.plus.fallbackVersion;
|
||||
withMockedProcessPlatform('darwin', 'arm64', () => {
|
||||
const info = platformDetector.detectPlatform(version, 'plus');
|
||||
assert.strictEqual(info.arch, 'arm64');
|
||||
assert.strictEqual(
|
||||
info.binaryName,
|
||||
`CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz`
|
||||
);
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl(version, 'plus'),
|
||||
`https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v${effectiveVersion}/CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Plus post-rename (>= 6.10.0-0) uses aarch64
|
||||
withMockedProcessPlatform('darwin', 'arm64', () => {
|
||||
const info = platformDetector.detectPlatform('6.10.0-0', 'plus');
|
||||
assert.strictEqual(info.arch, 'arm64');
|
||||
assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz');
|
||||
assert.strictEqual(
|
||||
platformDetector.getDownloadUrl('6.10.0-0', 'plus'),
|
||||
'https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v6.10.0-0/CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz'
|
||||
);
|
||||
});
|
||||
|
||||
// x64 remains amd64 for both backends
|
||||
withMockedProcessPlatform('linux', 'x64', () => {
|
||||
assert.strictEqual(
|
||||
platformDetector.detectPlatform('6.9.45', 'original').binaryName,
|
||||
'CLIProxyAPI_6.9.45_linux_amd64.tar.gz'
|
||||
);
|
||||
assert.strictEqual(
|
||||
platformDetector.detectPlatform('6.9.45-0', 'plus').binaryName,
|
||||
'CLIProxyAPIPlus_6.9.45-0_linux_amd64.tar.gz'
|
||||
);
|
||||
});
|
||||
});
|
||||
it('uses static no-plugin assets when the integrated Linux image opts in', () => {
|
||||
withEnvironmentVariable('CCS_CLIPROXY_NO_PLUGIN_ASSET', '1', () => {
|
||||
withMockedProcessPlatform('linux', 'x64', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { CLIProxyBackend } from '../../types';
|
||||
describe('cliproxy routing strategy service', () => {
|
||||
let tempHome = '';
|
||||
let scopedConfigDir = '';
|
||||
@@ -15,6 +15,11 @@ describe('cliproxy routing strategy service', () => {
|
||||
protocol: 'http' as const,
|
||||
isRemote: false,
|
||||
};
|
||||
let binaryBackend: CLIProxyBackend = 'original';
|
||||
let binaryVersion = '7.2.127-7';
|
||||
let installedVersionQueriedBackend: CLIProxyBackend | undefined = undefined;
|
||||
let loggedWarnings: Array<{ event: string; message: string; context?: Record<string, unknown> }> =
|
||||
[];
|
||||
let responseFactory: (() => Promise<Response>) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -28,6 +33,10 @@ describe('cliproxy routing strategy service', () => {
|
||||
};
|
||||
responseFactory = null;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
binaryBackend = 'original';
|
||||
binaryVersion = '7.2.127-7';
|
||||
installedVersionQueriedBackend = undefined;
|
||||
loggedWarnings = [];
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_DIR = scopedConfigDir;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
@@ -74,6 +83,26 @@ describe('cliproxy routing strategy service', () => {
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../binary-manager', () => ({
|
||||
getConfiguredBackend: () => binaryBackend,
|
||||
getInstalledCliproxyVersion: (backend?: CLIProxyBackend) => {
|
||||
installedVersionQueriedBackend = backend;
|
||||
return binaryVersion;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../services/logging', () => ({
|
||||
createLogger: () => ({
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: (event: string, message: string, context?: Record<string, unknown>) => {
|
||||
loggedWarnings.push({ event, message, context });
|
||||
},
|
||||
error: () => {},
|
||||
child: () => ({ warn: () => {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
return import(`../routing-strategy?test=${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
|
||||
@@ -365,4 +394,65 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(result.message).not.toContain('Pool routing is active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pool routing version compatibility (#1726)', () => {
|
||||
it('defines backend-specific minimum versions for pool routing', async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
expect(mod.POOL_ROUTING_MIN_VERSION).toEqual({
|
||||
original: '6.8.34',
|
||||
plus: '6.8.34-0',
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluates isPoolRoutingSupported correctly across original and plus thresholds', async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
expect(mod.isPoolRoutingSupported('original', '6.8.33')).toBe(false);
|
||||
expect(mod.isPoolRoutingSupported('original', '6.8.34')).toBe(true);
|
||||
expect(mod.isPoolRoutingSupported('original', '6.9.0')).toBe(true);
|
||||
|
||||
expect(mod.isPoolRoutingSupported('plus', '6.8.33-9')).toBe(false);
|
||||
expect(mod.isPoolRoutingSupported('plus', '6.8.34-0')).toBe(true);
|
||||
expect(mod.isPoolRoutingSupported('plus', '7.2.127-7')).toBe(true);
|
||||
|
||||
expect(mod.isPoolRoutingSupported('original', '')).toBe(false);
|
||||
expect(mod.isPoolRoutingSupported('plus', 'not-a-version')).toBe(false);
|
||||
});
|
||||
|
||||
it('warns with backend-specific metadata when plus binary is below minimum', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
binaryBackend = 'plus';
|
||||
binaryVersion = '6.8.33-9';
|
||||
loggedWarnings = [];
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = mod.enablePoolRouting(8317);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(installedVersionQueriedBackend).toBe('plus');
|
||||
expect(loggedWarnings).toHaveLength(1);
|
||||
expect(loggedWarnings[0].event).toBe('pool_routing.binary_below_minimum');
|
||||
expect(loggedWarnings[0].message).toContain('CLIProxy Plus');
|
||||
expect(loggedWarnings[0].context).toEqual({
|
||||
backend: 'plus',
|
||||
installedVersion: '6.8.33-9',
|
||||
minimumVersion: '6.8.34-0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not warn when plus binary meets the minimum version', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
binaryBackend = 'plus';
|
||||
binaryVersion = '6.8.34-0';
|
||||
loggedWarnings = [];
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = mod.enablePoolRouting(8317);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(installedVersionQueriedBackend).toBe('plus');
|
||||
expect(loggedWarnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
getCliproxyRoutingTarget,
|
||||
getRoutingErrorMessage,
|
||||
} from './routing-strategy-http';
|
||||
import type { CliproxyRoutingStrategy } from '../types';
|
||||
import type { CLIProxyBackend, CliproxyRoutingStrategy } from '../types';
|
||||
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
|
||||
import { getInstalledCliproxyVersion } from '../binary-manager';
|
||||
import { compareVersions } from '../../utils/update-checker';
|
||||
import { getConfiguredBackend, getInstalledCliproxyVersion } from '../binary-manager';
|
||||
import {
|
||||
type CLIProxyBackendMinVersions,
|
||||
meetsBackendMinimumVersion,
|
||||
} from '../binary/version-checker';
|
||||
import { getConfigYamlPath } from '../../config/loader/io-locks';
|
||||
import { createLogger } from '../../services/logging';
|
||||
|
||||
@@ -46,12 +49,18 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
|
||||
* max-retry-credentials and the cooling flip.
|
||||
* Older binaries silently ignore unknown keys — pool rails would appear active
|
||||
* but have no effect. Warn the user at enable time if below this version.
|
||||
*
|
||||
* NOTE: Update this constant when upstream first ships these keys.
|
||||
* Current best estimate based on spec; adjust after spike Test D confirms.
|
||||
*/
|
||||
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
|
||||
export const POOL_ROUTING_MIN_VERSION: CLIProxyBackendMinVersions = {
|
||||
original: '6.8.34',
|
||||
plus: '6.8.34-0',
|
||||
};
|
||||
|
||||
export function isPoolRoutingSupported(
|
||||
backend: CLIProxyBackend,
|
||||
installedVersion: string
|
||||
): boolean {
|
||||
return meetsBackendMinimumVersion(installedVersion, backend, POOL_ROUTING_MIN_VERSION);
|
||||
}
|
||||
/**
|
||||
* Pool-active override warning text. When pool routing is enabled the generator
|
||||
* forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so
|
||||
@@ -205,20 +214,21 @@ export function enablePoolRouting(
|
||||
|
||||
const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity();
|
||||
|
||||
// Spec step 3 / architecture: assert minimum CLIProxy version at enable time.
|
||||
// Stale binaries silently ignore max-retry-credentials and the cooling flip,
|
||||
// so pool rails would appear active but have no effect. Warn and proceed.
|
||||
try {
|
||||
const installedVersion = getInstalledCliproxyVersion();
|
||||
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
|
||||
const backend = getConfiguredBackend();
|
||||
const installedVersion = getInstalledCliproxyVersion(backend);
|
||||
if (!isPoolRoutingSupported(backend, installedVersion)) {
|
||||
const minimumVersion = POOL_ROUTING_MIN_VERSION[backend];
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
logger.warn(
|
||||
'pool_routing.binary_below_minimum',
|
||||
`CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` +
|
||||
`${backendLabel} v${installedVersion} is older than the pool routing minimum (v${minimumVersion}). ` +
|
||||
`The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` +
|
||||
`Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`,
|
||||
{
|
||||
backend,
|
||||
installedVersion,
|
||||
minimumVersion: POOL_ROUTING_MIN_VERSION,
|
||||
minimumVersion,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,14 @@ import * as path from 'path';
|
||||
|
||||
mock.module('../../../cliproxy/proxy/proxy-detector', () => ({
|
||||
detectRunningProxy: async () => ({ running: false, verified: false }),
|
||||
waitForProxyHealthy: async () => ({ running: false, verified: false }),
|
||||
reclaimOrphanedProxy: () => {},
|
||||
}));
|
||||
|
||||
describe('handleOrderSubcommand', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalNoColor: string | undefined;
|
||||
let logSpy: ReturnType<typeof spyOn>;
|
||||
let lines: string[];
|
||||
|
||||
@@ -35,7 +38,16 @@ describe('handleOrderSubcommand', () => {
|
||||
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir(), fileName),
|
||||
JSON.stringify({ type: 'claude', ...fields }, null, 2),
|
||||
JSON.stringify({ type: 'claude', email: fileName, ...fields }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
}
|
||||
|
||||
function writeAgyAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
|
||||
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir(), fileName),
|
||||
JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
}
|
||||
@@ -47,6 +59,24 @@ describe('handleOrderSubcommand', () => {
|
||||
>;
|
||||
}
|
||||
|
||||
async function loadRegistry() {
|
||||
return import(`../../../cliproxy/accounts/registry?order-subcommand-reg=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function configureBackend(backend: 'original' | 'plus', version: string): Promise<void> {
|
||||
const { mutateConfig, invalidateConfigCache } = await import(
|
||||
'../../../config/config-loader-facade'
|
||||
);
|
||||
mutateConfig((cfg) => {
|
||||
if (!cfg.cliproxy) cfg.cliproxy = {};
|
||||
cfg.cliproxy.backend = backend;
|
||||
});
|
||||
invalidateConfigCache();
|
||||
const verPath = path.join(tempHome, '.ccs', 'cliproxy', 'bin', backend, '.version');
|
||||
fs.mkdirSync(path.dirname(verPath), { recursive: true });
|
||||
fs.writeFileSync(verPath, version.trim() + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function registerClaude(): Promise<{
|
||||
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
|
||||
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
|
||||
@@ -64,7 +94,9 @@ describe('handleOrderSubcommand', () => {
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalNoColor = process.env.NO_COLOR;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.NO_COLOR = '1';
|
||||
process.exitCode = 0;
|
||||
lines = [];
|
||||
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||
@@ -80,14 +112,18 @@ describe('handleOrderSubcommand', () => {
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
if (originalNoColor !== undefined) {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
} else {
|
||||
delete process.env.NO_COLOR;
|
||||
}
|
||||
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.
|
||||
// Explicit below-minimum version to prove show is ungated on old binaries.
|
||||
await configureBackend('original', '6.6.105');
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
|
||||
|
||||
@@ -96,6 +132,7 @@ describe('handleOrderSubcommand', () => {
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
|
||||
const output = lines.join('\n');
|
||||
// b@x.com (priority 5) must appear before a@x.com (priority 1).
|
||||
@@ -105,7 +142,6 @@ describe('handleOrderSubcommand', () => {
|
||||
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');
|
||||
@@ -115,6 +151,8 @@ describe('handleOrderSubcommand', () => {
|
||||
});
|
||||
|
||||
it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => {
|
||||
// Explicit below-minimum version on Plus to prove show is ungated on old binaries.
|
||||
await configureBackend('plus', '6.6.105-0');
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||
|
||||
@@ -123,6 +161,7 @@ describe('handleOrderSubcommand', () => {
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('no priority set');
|
||||
@@ -132,6 +171,8 @@ describe('handleOrderSubcommand', () => {
|
||||
|
||||
describe('--reset clears residual priorities (proxy stopped -> direct write)', () => {
|
||||
it('removes the priority field from auth files and reports per-file results', async () => {
|
||||
// Explicit below-minimum version to prove reset is ungated on old binaries.
|
||||
await configureBackend('original', '6.6.105');
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear
|
||||
|
||||
@@ -141,6 +182,7 @@ describe('handleOrderSubcommand', () => {
|
||||
saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
|
||||
|
||||
await runOrderSubcommand(['claude', '--reset']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
|
||||
// Residual priority is actually gone from disk (not just config deleted).
|
||||
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
|
||||
@@ -153,16 +195,146 @@ describe('handleOrderSubcommand', () => {
|
||||
});
|
||||
|
||||
it('reports already-clear files and still resets when no priorities exist', async () => {
|
||||
// Explicit below-minimum version on Plus to prove reset is ungated on old binaries.
|
||||
await configureBackend('plus', '6.6.105-0');
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
|
||||
const { registerAccount } = await registerClaude();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude', '--reset']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('reset to file order');
|
||||
expect(output).toContain('no priority set');
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary version capability gate (#1724)', () => {
|
||||
const cases: Array<{
|
||||
backend: 'original' | 'plus';
|
||||
belowMinVersion: string;
|
||||
atMinVersion: string;
|
||||
backendLabel: string;
|
||||
requiredMinVersion: string;
|
||||
}> = [
|
||||
{
|
||||
backend: 'original',
|
||||
belowMinVersion: '6.6.105',
|
||||
atMinVersion: '6.6.106',
|
||||
backendLabel: 'CLIProxy',
|
||||
requiredMinVersion: '6.6.106',
|
||||
},
|
||||
{
|
||||
backend: 'plus',
|
||||
belowMinVersion: '6.6.105-0',
|
||||
atMinVersion: '6.6.107-0',
|
||||
backendLabel: 'CLIProxy Plus',
|
||||
requiredMinVersion: '6.6.107-0',
|
||||
},
|
||||
];
|
||||
|
||||
for (const {
|
||||
backend,
|
||||
belowMinVersion,
|
||||
atMinVersion,
|
||||
backendLabel,
|
||||
requiredMinVersion,
|
||||
} of cases) {
|
||||
describe(`${backend} backend`, () => {
|
||||
it(`refuses --set below minimum version (${belowMinVersion}) without mutating files`, async () => {
|
||||
await configureBackend(backend, belowMinVersion);
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||
|
||||
const { registerAccount, loadDrainOrderConfig } = await loadRegistry();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('[X]');
|
||||
expect(output).toContain(
|
||||
`${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).`
|
||||
);
|
||||
expect(output).toContain(
|
||||
`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`
|
||||
);
|
||||
expect(output).not.toContain('Set priorities');
|
||||
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
|
||||
expect('priority' in readAuthFile('claude-b.json')).toBe(false);
|
||||
expect(loadDrainOrderConfig('claude')).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`refuses --by-tier below minimum version (${belowMinVersion}) without mutating files`, async () => {
|
||||
await configureBackend(backend, belowMinVersion);
|
||||
writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' });
|
||||
writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' });
|
||||
|
||||
const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry();
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
|
||||
setAccountTier('agy', 'a@x.com', 'pro');
|
||||
setAccountTier('agy', 'b@x.com', 'free');
|
||||
|
||||
await runOrderSubcommand(['agy', '--by-tier']);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('[X]');
|
||||
expect(output).toContain(
|
||||
`${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).`
|
||||
);
|
||||
expect(output).toContain(
|
||||
`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`
|
||||
);
|
||||
expect(output).not.toContain('Set priorities');
|
||||
expect('priority' in readAuthFile('antigravity-a.json')).toBe(false);
|
||||
expect('priority' in readAuthFile('antigravity-b.json')).toBe(false);
|
||||
expect(loadDrainOrderConfig('agy')).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`applies and persists --set at minimum version (${atMinVersion})`, async () => {
|
||||
await configureBackend(backend, atMinVersion);
|
||||
writeAuthFile('claude-a.json', { email: 'a@x.com' });
|
||||
writeAuthFile('claude-b.json', { email: 'b@x.com' });
|
||||
|
||||
const { registerAccount, loadDrainOrderConfig } = await loadRegistry();
|
||||
registerAccount('claude', 'claude-a.json', 'a@x.com');
|
||||
registerAccount('claude', 'claude-b.json', 'b@x.com');
|
||||
|
||||
await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']);
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Set priorities for 2 account(s).');
|
||||
expect('priority' in readAuthFile('claude-a.json')).toBe(true);
|
||||
expect(loadDrainOrderConfig('claude')?.mode).toBe('manual');
|
||||
});
|
||||
|
||||
it(`applies and persists --by-tier at minimum version (${atMinVersion})`, async () => {
|
||||
await configureBackend(backend, atMinVersion);
|
||||
writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' });
|
||||
writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' });
|
||||
|
||||
const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry();
|
||||
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
|
||||
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
|
||||
setAccountTier('agy', 'a@x.com', 'pro');
|
||||
setAccountTier('agy', 'b@x.com', 'free');
|
||||
|
||||
await runOrderSubcommand(['agy', '--by-tier']);
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Set priorities for 2 account(s).');
|
||||
expect('priority' in readAuthFile('antigravity-a.json')).toBe(true);
|
||||
expect(loadDrainOrderConfig('agy')?.mode).toBe('tier');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../
|
||||
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||
import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry';
|
||||
import { getProviderAccounts } from '../../cliproxy/accounts/query';
|
||||
import { getConfiguredBackend, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
|
||||
import {
|
||||
computeManualDrainOrder,
|
||||
computeTierDrainOrder,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
resolveEffectiveDrainOrder,
|
||||
clearDrainOrderPriorities,
|
||||
tieBreakKey,
|
||||
DRAIN_ORDER_MIN_VERSION,
|
||||
isDrainOrderSupported,
|
||||
type DrainOrderEntry,
|
||||
type DrainOrderInput,
|
||||
} from '../../cliproxy/accounts/drain-order';
|
||||
@@ -45,6 +48,27 @@ function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): s
|
||||
return tierDerived ? label : dim(tier);
|
||||
}
|
||||
|
||||
function ensureDrainOrderBinarySupport(): boolean {
|
||||
const backend = getConfiguredBackend();
|
||||
const installedVersion = getInstalledCliproxyVersion(backend);
|
||||
if (!isDrainOrderSupported(backend, installedVersion)) {
|
||||
const minimumVersion = DRAIN_ORDER_MIN_VERSION[backend];
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
console.log(
|
||||
fail(
|
||||
`${backendLabel} v${installedVersion} does not support drain-order priorities (requires v${minimumVersion} or newer).`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
info(`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`)
|
||||
);
|
||||
console.log('');
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function printOrderTable(
|
||||
entries: DrainOrderEntry[],
|
||||
showCurrentPriority: boolean,
|
||||
@@ -281,6 +305,9 @@ async function handleOrderByTier(provider: CLIProxyProvider): Promise<void> {
|
||||
|
||||
const entries = computeTierDrainOrder(accounts);
|
||||
|
||||
if (!ensureDrainOrderBinarySupport()) {
|
||||
return;
|
||||
}
|
||||
console.log(subheader('Computed tier-based drain order:'));
|
||||
printOrderTable(entries, false);
|
||||
console.log('');
|
||||
@@ -404,6 +431,10 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ensureDrainOrderBinarySupport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(subheader('Computed manual drain order:'));
|
||||
printOrderTable(entries, false);
|
||||
console.log('');
|
||||
|
||||
Reference in new issue
Block a user