feat(cliproxy): add backend selection for CLIProxyAPI vs CLIProxyAPIPlus

Add cliproxy.backend config field to choose between CLIProxyAPI (original)
and CLIProxyAPIPlus backends. Includes CLI --backend flag, Dashboard toggle,
and provider validation to block Kiro/ghcp on original backend.
This commit is contained in:
kaitranntt
2026-01-18 10:10:01 -05:00
parent ca86772208
commit 8ade4a6b26
12 changed files with 601 additions and 71 deletions
+64 -24
View File
@@ -8,9 +8,10 @@
import { info, warn } from '../utils/ui'; import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
import { BinaryInfo, BinaryManagerConfig } from './types'; import { BinaryInfo, BinaryManagerConfig } from './types';
import { CLIPROXY_FALLBACK_VERSION, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector';
import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service';
import { waitForPortFree } from '../utils/port-utils'; import { waitForPortFree } from '../utils/port-utils';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { import {
UpdateCheckResult, UpdateCheckResult,
checkForUpdates, checkForUpdates,
@@ -27,24 +28,53 @@ import {
ensureBinary, ensureBinary,
} from './binary'; } from './binary';
/** Default configuration (uses CLIProxyAPIPlus fork with Kiro + Copilot support) */ type CLIProxyBackend = 'original' | 'plus';
const DEFAULT_CONFIG: BinaryManagerConfig = {
version: CLIPROXY_FALLBACK_VERSION, /**
releaseUrl: 'https://github.com/router-for-me/CLIProxyAPIPlus/releases/download', * Get backend from config or default to 'plus'
binPath: getBinDir(), */
maxRetries: 3, function getConfiguredBackend(): CLIProxyBackend {
verbose: false, try {
forceVersion: false, const config = loadOrCreateUnifiedConfig();
}; return config.cliproxy?.backend || DEFAULT_BACKEND;
} catch {
return DEFAULT_BACKEND;
}
}
/**
* Get backend-specific binary directory.
* Stores binaries in separate dirs: bin/original/ and bin/plus/
*/
function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
const baseDir = getBinDir();
return `${baseDir}/${backend}`;
}
/** Default configuration (uses backend from config.yaml or defaults to 'plus') */
function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig {
const backendConfig = BACKEND_CONFIG[backend];
return {
version: backendConfig.fallbackVersion,
releaseUrl: `https://github.com/${backendConfig.repo}/releases/download`,
binPath: getBackendBinDir(backend),
maxRetries: 3,
verbose: false,
forceVersion: false,
};
}
/** /**
* Binary Manager class for CLIProxyAPI binary lifecycle * Binary Manager class for CLIProxyAPI binary lifecycle
*/ */
export class BinaryManager { export class BinaryManager {
private config: BinaryManagerConfig; private config: BinaryManagerConfig;
private backend: CLIProxyBackend;
constructor(config: Partial<BinaryManagerConfig> = {}) { constructor(config: Partial<BinaryManagerConfig> = {}, backend?: CLIProxyBackend) {
this.config = { ...DEFAULT_CONFIG, ...config }; this.backend = backend ?? getConfiguredBackend();
const defaultConfig = createDefaultConfig(this.backend);
this.config = { ...defaultConfig, ...config };
} }
/** Ensure binary is available (download if missing, update if outdated) */ /** Ensure binary is available (download if missing, update if outdated) */
@@ -80,36 +110,44 @@ export class BinaryManager {
/** Convenience function respecting version pin */ /** Convenience function respecting version pin */
export async function ensureCLIProxyBinary(verbose = false): Promise<string> { export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
const backend = getConfiguredBackend();
const pinnedVersion = getPinnedVersion(); const pinnedVersion = getPinnedVersion();
if (pinnedVersion) { if (pinnedVersion) {
if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`); if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`);
return new BinaryManager({ return new BinaryManager(
version: pinnedVersion, {
verbose, version: pinnedVersion,
forceVersion: true, verbose,
}).ensureBinary(); forceVersion: true,
},
backend
).ensureBinary();
} }
return new BinaryManager({ verbose }).ensureBinary(); return new BinaryManager({ verbose }, backend).ensureBinary();
} }
/** Check if CLIProxyAPI binary is installed */ /** Check if CLIProxyAPI binary is installed */
export function isCLIProxyInstalled(): boolean { export function isCLIProxyInstalled(): boolean {
return new BinaryManager().isBinaryInstalled(); const backend = getConfiguredBackend();
return new BinaryManager({}, backend).isBinaryInstalled();
} }
/** Get CLIProxyAPI binary path (may not exist) */ /** Get CLIProxyAPI binary path (may not exist) */
export function getCLIProxyPath(): string { export function getCLIProxyPath(): string {
return new BinaryManager().getBinaryPath(); const backend = getConfiguredBackend();
return new BinaryManager({}, backend).getBinaryPath();
} }
/** Get installed CLIProxyAPI version from .version file */ /** Get installed CLIProxyAPI version from .version file */
export function getInstalledCliproxyVersion(): string { export function getInstalledCliproxyVersion(): string {
return readInstalledVersion(getBinDir(), CLIPROXY_FALLBACK_VERSION); const backend = getConfiguredBackend();
return readInstalledVersion(getBackendBinDir(backend), BACKEND_CONFIG[backend].fallbackVersion);
} }
/** Install a specific version of CLIProxyAPI */ /** Install a specific version of CLIProxyAPI */
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> { export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
const manager = new BinaryManager({ version, verbose, forceVersion: true }); const backend = getConfiguredBackend();
const manager = new BinaryManager({ version, verbose, forceVersion: true }, backend);
// Check if proxy is running and stop it first // Check if proxy is running and stop it first
if (isProxyRunning()) { if (isProxyRunning()) {
@@ -140,7 +178,8 @@ export async function installCliproxyVersion(version: string, verbose = false):
/** Fetch the latest CLIProxyAPI version from GitHub API */ /** Fetch the latest CLIProxyAPI version from GitHub API */
export async function fetchLatestCliproxyVersion(): Promise<string> { export async function fetchLatestCliproxyVersion(): Promise<string> {
const result = await new BinaryManager().checkForUpdates(); const backend = getConfiguredBackend();
const result = await new BinaryManager({}, backend).checkForUpdates();
return result.latestVersion; return result.latestVersion;
} }
@@ -159,7 +198,8 @@ export interface CliproxyUpdateCheckResult {
/** Check for CLIProxyAPI binary updates */ /** Check for CLIProxyAPI binary updates */
export async function checkCliproxyUpdate(): Promise<CliproxyUpdateCheckResult> { export async function checkCliproxyUpdate(): Promise<CliproxyUpdateCheckResult> {
const result = await new BinaryManager().checkForUpdates(); const backend = getConfiguredBackend();
const result = await new BinaryManager({}, backend).checkForUpdates();
// Import isNewerVersion for stability check // Import isNewerVersion for stability check
const { isNewerVersion } = await import('./binary/version-checker'); const { isNewerVersion } = await import('./binary/version-checker');
+16 -1
View File
@@ -30,7 +30,8 @@ import {
} from './config-generator'; } from './config-generator';
import { checkRemoteProxy } from './remote-proxy-client'; import { checkRemoteProxy } from './remote-proxy-client';
import { isAuthenticated } from './auth-handler'; import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from './types';
import { DEFAULT_BACKEND } from './platform-detector';
import { configureProviderModel, getCurrentModel } from './model-config'; import { configureProviderModel, getCurrentModel } from './model-config';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager';
@@ -141,6 +142,20 @@ export async function execClaudeWithCLIProxy(
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
// This filters proxy flags from args and returns resolved config // This filters proxy flags from args and returns resolved config
const unifiedConfig = loadOrCreateUnifiedConfig(); const unifiedConfig = loadOrCreateUnifiedConfig();
// 0a. Runtime backend/provider validation - block kiro/ghcp if backend=original
const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND;
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(provider)) {
console.error('');
console.error(fail(`${provider} requires CLIProxyAPIPlus backend`));
console.error('');
console.error('To use this provider, either:');
console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml');
console.error(' 2. Use --backend=plus flag: ccs ' + provider + ' --backend=plus');
console.error('');
throw new Error(`Provider ${provider} requires Plus backend`);
}
const cliproxyServerConfig = unifiedConfig.cliproxy_server; const cliproxyServerConfig = unifiedConfig.cliproxy_server;
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
remote: cliproxyServerConfig?.remote remote: cliproxyServerConfig?.remote
+69 -19
View File
@@ -7,12 +7,35 @@
import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types'; import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types';
// Phase 1 will add CLIProxyBackend type - using string literal temporarily
type CLIProxyBackend = 'original' | 'plus';
/** Backend configuration */
export const BACKEND_CONFIG = {
original: {
repo: 'router-for-me/CLIProxyAPI',
binaryPrefix: 'CLIProxyAPI',
executable: 'cli-proxy-api',
fallbackVersion: '6.7.8',
},
plus: {
repo: 'router-for-me/CLIProxyAPIPlus',
binaryPrefix: 'CLIProxyAPIPlus',
executable: 'cli-proxy-api-plus',
fallbackVersion: '6.7.8-0',
},
} as const;
/** Default backend */
export const DEFAULT_BACKEND: CLIProxyBackend = 'plus';
/** /**
* CLIProxyAPIPlus fallback version (used when GitHub API unavailable) * CLIProxyAPIPlus fallback version (used when GitHub API unavailable)
* Auto-update fetches latest from GitHub; this is only a safety net * Auto-update fetches latest from GitHub; this is only a safety net
* Note: CLIProxyAPIPlus uses v6.6.X-0 suffix pattern * Note: CLIProxyAPIPlus uses v6.6.X-0 suffix pattern
* @deprecated Use getFallbackVersion() or BACKEND_CONFIG instead
*/ */
export const CLIPROXY_FALLBACK_VERSION = '6.6.40-0'; export const CLIPROXY_FALLBACK_VERSION = BACKEND_CONFIG[DEFAULT_BACKEND].fallbackVersion;
/** /**
* Maximum stable version cap - prevents auto-update to known unstable releases * Maximum stable version cap - prevents auto-update to known unstable releases
@@ -48,10 +71,14 @@ const ARCH_MAP: Record<string, SupportedArch | undefined> = {
/** /**
* Detect current platform and return binary info * Detect current platform and return binary info
* @param version Optional version for binaryName (defaults to fallback) * @param version Optional version for binaryName (defaults to backend fallback)
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @throws Error if platform is unsupported * @throws Error if platform is unsupported
*/ */
export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): PlatformInfo { export function detectPlatform(
version?: string,
backend: CLIProxyBackend = DEFAULT_BACKEND
): PlatformInfo {
const nodePlatform = process.platform; const nodePlatform = process.platform;
const nodeArch = process.arch; const nodeArch = process.arch;
@@ -71,8 +98,10 @@ export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): Pla
); );
} }
const config = BACKEND_CONFIG[backend];
const ver = version || config.fallbackVersion;
const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz'; const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz';
const binaryName = `CLIProxyAPIPlus_${version}_${os}_${arch}.${extension}`; const binaryName = `${config.binaryPrefix}_${ver}_${os}_${arch}.${extension}`;
return { return {
os, os,
@@ -84,41 +113,62 @@ export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): Pla
/** /**
* Get executable name based on platform * Get executable name based on platform
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @returns Binary executable name (with .exe on Windows) * @returns Binary executable name (with .exe on Windows)
* Note: The actual binary inside the archive is named 'cli-proxy-api-plus'
*/ */
export function getExecutableName(): string { export function getExecutableName(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
const platform = detectPlatform(); const config = BACKEND_CONFIG[backend];
return platform.os === 'windows' ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus'; const platform = detectPlatform(undefined, backend);
return platform.os === 'windows' ? `${config.executable}.exe` : config.executable;
} }
/** /**
* Get the name of the binary inside the archive * Get the name of the binary inside the archive
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @returns Binary name as it appears in the tar.gz/zip * @returns Binary name as it appears in the tar.gz/zip
*/ */
export function getArchiveBinaryName(): string { export function getArchiveBinaryName(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
const platform = detectPlatform(); return getExecutableName(backend);
return platform.os === 'windows' ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus';
} }
/** /**
* Get download URL for current platform * Get download URL for current platform
* @param version Version to download (defaults to fallback version) * @param version Version to download (defaults to backend fallback version)
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @returns Full GitHub release download URL * @returns Full GitHub release download URL
*/ */
export function getDownloadUrl(version: string = CLIPROXY_FALLBACK_VERSION): string { export function getDownloadUrl(
const platform = detectPlatform(version); version?: string,
const baseUrl = `https://github.com/router-for-me/CLIProxyAPIPlus/releases/download/v${version}`; backend: CLIProxyBackend = DEFAULT_BACKEND
return `${baseUrl}/${platform.binaryName}`; ): string {
const config = BACKEND_CONFIG[backend];
const ver = version || config.fallbackVersion;
const platform = detectPlatform(ver, backend);
return `https://github.com/${config.repo}/releases/download/v${ver}/${platform.binaryName}`;
} }
/** /**
* Get checksums.txt URL for version * Get checksums.txt URL for version
* @param version Version to get checksums for (defaults to fallback version) * @param version Version to get checksums for (defaults to backend fallback version)
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @returns Full URL to checksums.txt * @returns Full URL to checksums.txt
*/ */
export function getChecksumsUrl(version: string = CLIPROXY_FALLBACK_VERSION): string { export function getChecksumsUrl(
return `https://github.com/router-for-me/CLIProxyAPIPlus/releases/download/v${version}/checksums.txt`; version?: string,
backend: CLIProxyBackend = DEFAULT_BACKEND
): string {
const config = BACKEND_CONFIG[backend];
const ver = version || config.fallbackVersion;
return `https://github.com/${config.repo}/releases/download/v${ver}/checksums.txt`;
}
/**
* Get fallback version for backend
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @returns Fallback version string
*/
export function getFallbackVersion(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return BACKEND_CONFIG[backend].fallbackVersion;
} }
/** /**
+11 -4
View File
@@ -19,7 +19,9 @@ import {
clearPinnedVersion, clearPinnedVersion,
isVersionPinned, isVersionPinned,
} from '../binary-manager'; } from '../binary-manager';
import { CLIPROXY_FALLBACK_VERSION } from '../platform-detector'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector';
import { CLIProxyBackend } from '../types';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
/** Binary status result */ /** Binary status result */
export interface BinaryStatusResult { export interface BinaryStatusResult {
@@ -28,6 +30,7 @@ export interface BinaryStatusResult {
pinnedVersion: string | null; pinnedVersion: string | null;
binaryPath: string; binaryPath: string;
fallbackVersion: string; fallbackVersion: string;
backend: CLIProxyBackend;
} }
/** Install result */ /** Install result */
@@ -48,15 +51,19 @@ export interface LatestVersionResult {
} }
/** /**
* Get current binary status * Get current binary status for a specific backend
*/ */
export function getBinaryStatus(): BinaryStatusResult { export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
const effectiveBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
const backendConfig = BACKEND_CONFIG[effectiveBackend];
return { return {
installed: isCLIProxyInstalled(), installed: isCLIProxyInstalled(),
currentVersion: getInstalledCliproxyVersion(), currentVersion: getInstalledCliproxyVersion(),
pinnedVersion: getPinnedVersion(), pinnedVersion: getPinnedVersion(),
binaryPath: getCLIProxyPath(), binaryPath: getCLIProxyPath(),
fallbackVersion: CLIPROXY_FALLBACK_VERSION, fallbackVersion: backendConfig.fallbackVersion,
backend: effectiveBackend,
}; };
} }
+33 -1
View File
@@ -8,8 +8,10 @@
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider } from '../types'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { DEFAULT_BACKEND } from '../platform-detector';
import { isUnifiedMode } from '../../config/unified-config-loader'; import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config-generator'; import { deleteConfigForPort } from '../config-generator';
import { deleteSessionLockForPort } from '../session-tracker'; import { deleteSessionLockForPort } from '../session-tracker';
@@ -64,6 +66,22 @@ export function validateProfileName(name: string): string | null {
return null; return null;
} }
/**
* Validate provider/backend compatibility
* Returns error message if provider requires Plus backend but original is configured
*/
export function validateProviderBackend(provider: CLIProxyProfileName): string | null {
const config = loadOrCreateUnifiedConfig();
const backend: CLIProxyBackend = config.cliproxy?.backend ?? DEFAULT_BACKEND;
// Normalize provider to lowercase for case-insensitive comparison
const normalizedProvider = provider.toLowerCase() as CLIProxyProvider;
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(normalizedProvider)) {
return `${provider} requires CLIProxyAPIPlus. Set \`cliproxy.backend: plus\` in config.yaml or use --backend=plus`;
}
return null;
}
/** /**
* Check if CLIProxy variant profile exists * Check if CLIProxy variant profile exists
*/ */
@@ -88,6 +106,12 @@ export function createVariant(
account?: string account?: string
): VariantOperationResult { ): VariantOperationResult {
try { try {
// Validate provider/backend compatibility (block kiro/ghcp on original backend)
const backendError = validateProviderBackend(provider);
if (backendError) {
return { success: false, error: backendError };
}
// Allocate unique port for this variant // Allocate unique port for this variant
const port = getNextAvailablePort(); const port = getNextAvailablePort();
@@ -188,6 +212,14 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
// Update config entry if provider or account changed // Update config entry if provider or account changed
if (updates.provider !== undefined || updates.account !== undefined) { if (updates.provider !== undefined || updates.account !== undefined) {
const newProvider = updates.provider ?? existing.provider; const newProvider = updates.provider ?? existing.provider;
// Validate provider/backend compatibility on provider change
if (updates.provider !== undefined) {
const backendError = validateProviderBackend(updates.provider);
if (backendError) {
return { success: false, error: backendError };
}
}
const newAccount = updates.account !== undefined ? updates.account : existing.account; const newAccount = updates.account !== undefined ? updates.account : existing.account;
if (isUnifiedMode()) { if (isUnifiedMode()) {
+12
View File
@@ -119,6 +119,18 @@ export interface DownloadResult {
*/ */
export type CLIProxyProvider = 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; export type CLIProxyProvider = 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (no Kiro/ghcp support)
* - plus: CLIProxyAPIPlus (Kiro/ghcp support, default)
*/
export type CLIProxyBackend = 'original' | 'plus';
/**
* Providers that require CLIProxyAPIPlus backend
*/
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp'];
/** /**
* CLIProxy config.yaml structure (minimal) * CLIProxy config.yaml structure (minimal)
*/ */
+74 -19
View File
@@ -29,11 +29,11 @@ import {
} from '../cliproxy/account-manager'; } from '../cliproxy/account-manager';
import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher'; import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher';
import { isOnCooldown } from '../cliproxy/quota-manager'; import { isOnCooldown } from '../cliproxy/quota-manager';
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { DEFAULT_BACKEND, CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog';
import { CLIProxyProvider } from '../cliproxy/types'; import { CLIProxyProvider, CLIProxyBackend } from '../cliproxy/types';
import { isUnifiedMode } from '../config/unified-config-loader'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { import {
initUI, initUI,
header, header,
@@ -68,6 +68,47 @@ import {
// ARGUMENT PARSING // ARGUMENT PARSING
// ============================================================================ // ============================================================================
/**
* Parse --backend flag from args
* Returns the backend value and remaining args without --backend flag
*/
function parseBackendArg(args: string[]): {
backend: CLIProxyBackend | undefined;
remainingArgs: string[];
} {
const backendIdx = args.indexOf('--backend');
if (backendIdx === -1) {
// Also check for --backend=value format
const backendEqualsIdx = args.findIndex((a) => a.startsWith('--backend='));
if (backendEqualsIdx !== -1) {
const value = args[backendEqualsIdx].split('=')[1] as CLIProxyBackend;
if (value !== 'original' && value !== 'plus') {
return { backend: undefined, remainingArgs: args };
}
const remainingArgs = [...args];
remainingArgs.splice(backendEqualsIdx, 1);
return { backend: value, remainingArgs };
}
return { backend: undefined, remainingArgs: args };
}
const value = args[backendIdx + 1];
if (value !== 'original' && value !== 'plus') {
return { backend: undefined, remainingArgs: args };
}
const remainingArgs = [...args];
remainingArgs.splice(backendIdx, 2);
return { backend: value, remainingArgs };
}
/**
* Get effective backend (CLI flag > config.yaml > default)
*/
function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
if (cliBackend) return cliBackend;
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend ?? DEFAULT_BACKEND;
}
interface CliproxyProfileArgs { interface CliproxyProfileArgs {
name?: string; name?: string;
provider?: CLIProxyProfileName; provider?: CLIProxyProfileName;
@@ -431,14 +472,18 @@ async function handleProxyStatus(): Promise<void> {
console.log(''); console.log('');
} }
async function showStatus(verbose: boolean): Promise<void> { async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise<void> {
await initUI(); await initUI();
const status = getBinaryStatus(); const status = getBinaryStatus(backend);
console.log(''); console.log('');
console.log(color('CLIProxy Plus Status', 'primary')); const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy (Original)';
console.log(color(`${backendLabel} Status`, 'primary'));
console.log(''); console.log('');
console.log(
` Backend: ${color(backend, 'info')}${backend === DEFAULT_BACKEND ? dim(' (default)') : ''}`
);
if (status.installed) { if (status.installed) {
console.log(` Installed: ${color('Yes', 'success')}`); console.log(` Installed: ${color('Yes', 'success')}`);
const versionLabel = status.pinnedVersion const versionLabel = status.pinnedVersion
@@ -576,7 +621,13 @@ async function showHelp(): Promise<void> {
['--update', 'Unpin and update to latest version'], ['--update', 'Unpin and update to latest version'],
], ],
], ],
['Options:', [['--verbose, -v', 'Show detailed quota fetch diagnostics']]], [
'Options:',
[
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
['--verbose, -v', 'Show detailed quota fetch diagnostics'],
],
],
]; ];
for (const [title, cmds] of sections) { for (const [title, cmds] of sections) {
@@ -909,16 +960,20 @@ async function handleQuotaStatus(verbose = false): Promise<void> {
// ============================================================================ // ============================================================================
export async function handleCliproxyCommand(args: string[]): Promise<void> { export async function handleCliproxyCommand(args: string[]): Promise<void> {
const verbose = args.includes('--verbose') || args.includes('-v'); // Parse --backend flag first (before other processing)
const command = args[0]; const { backend: cliBackend, remainingArgs } = parseBackendArg(args);
const effectiveBackend = getEffectiveBackend(cliBackend);
if (args.includes('--help') || args.includes('-h')) { const verbose = remainingArgs.includes('--verbose') || remainingArgs.includes('-v');
const command = remainingArgs[0];
if (remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
await showHelp(); await showHelp();
return; return;
} }
if (command === 'create') { if (command === 'create') {
await handleCreate(args.slice(1)); await handleCreate(remainingArgs.slice(1));
return; return;
} }
@@ -928,7 +983,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
} }
if (command === 'remove' || command === 'delete' || command === 'rm') { if (command === 'remove' || command === 'delete' || command === 'rm') {
await handleRemove(args.slice(1)); await handleRemove(remainingArgs.slice(1));
return; return;
} }
@@ -949,17 +1004,17 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
// Quota management commands // Quota management commands
if (command === 'default') { if (command === 'default') {
await handleSetDefault(args.slice(1)); await handleSetDefault(remainingArgs.slice(1));
return; return;
} }
if (command === 'pause') { if (command === 'pause') {
await handlePauseAccount(args.slice(1)); await handlePauseAccount(remainingArgs.slice(1));
return; return;
} }
if (command === 'resume') { if (command === 'resume') {
await handleResumeAccount(args.slice(1)); await handleResumeAccount(remainingArgs.slice(1));
return; return;
} }
@@ -968,9 +1023,9 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return; return;
} }
const installIdx = args.indexOf('--install'); const installIdx = remainingArgs.indexOf('--install');
if (installIdx !== -1) { if (installIdx !== -1) {
let version = args[installIdx + 1]; let version = remainingArgs[installIdx + 1];
if (!version || version.startsWith('-')) { if (!version || version.startsWith('-')) {
console.error(fail('Missing version argument for --install')); console.error(fail('Missing version argument for --install'));
console.error(' Usage: ccs cliproxy --install <version>'); console.error(' Usage: ccs cliproxy --install <version>');
@@ -983,10 +1038,10 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return; return;
} }
if (args.includes('--latest') || args.includes('--update')) { if (remainingArgs.includes('--latest') || remainingArgs.includes('--update')) {
await handleInstallLatest(verbose); await handleInstallLatest(verbose);
return; return;
} }
await showStatus(verbose); await showStatus(verbose, effectiveBackend);
} }
+3
View File
@@ -115,6 +115,8 @@ export interface TokenRefreshSettings {
* CLIProxy configuration section. * CLIProxy configuration section.
*/ */
export interface CLIProxyConfig { export interface CLIProxyConfig {
/** Backend selection: 'original' or 'plus' (default: 'plus') */
backend?: 'original' | 'plus';
/** Nickname to email mapping for OAuth accounts */ /** Nickname to email mapping for OAuth accounts */
oauth_accounts: OAuthAccounts; oauth_accounts: OAuthAccounts;
/** Built-in providers (read-only, for reference) */ /** Built-in providers (read-only, for reference) */
@@ -530,6 +532,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
accounts: {}, accounts: {},
profiles: {}, profiles: {},
cliproxy: { cliproxy: {
backend: 'plus',
oauth_accounts: {}, oauth_accounts: {},
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'], providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'],
variants: {}, variants: {},
+55
View File
@@ -10,6 +10,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
import { testConnection } from '../../cliproxy/remote-proxy-client'; import { testConnection } from '../../cliproxy/remote-proxy-client';
import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service';
import { import {
DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_CLIPROXY_SERVER_CONFIG,
CliproxyServerConfig, CliproxyServerConfig,
@@ -65,6 +66,60 @@ router.put('/', async (req: Request, res: Response) => {
} }
}); });
/**
* GET /api/cliproxy-server/backend - Get CLIProxy backend setting
*/
router.get('/backend', async (_req: Request, res: Response) => {
try {
const config = await loadOrCreateUnifiedConfig();
res.json({ backend: config.cliproxy?.backend ?? 'plus' });
} catch (error) {
console.error('[cliproxy-server-routes] Failed to load backend config:', error);
res.status(500).json({ error: 'Failed to load backend config' });
}
});
/**
* PUT /api/cliproxy-server/backend - Update CLIProxy backend setting
*/
router.put('/backend', async (req: Request, res: Response) => {
try {
const { backend, force } = req.body;
if (backend !== 'original' && backend !== 'plus') {
res.status(400).json({ error: 'Invalid backend. Must be "original" or "plus"' });
return;
}
// Check if proxy is running - warn about restart requirement
const config = await loadOrCreateUnifiedConfig();
const currentBackend = config.cliproxy?.backend ?? 'plus';
if (currentBackend !== backend && isProxyRunning() && !force) {
res.status(409).json({
error: 'Proxy is running. Stop proxy first or use force=true to change backend.',
proxyRunning: true,
currentBackend,
});
return;
}
if (!config.cliproxy) {
config.cliproxy = {
backend,
oauth_accounts: {},
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'],
variants: {},
};
} else {
config.cliproxy.backend = backend;
}
await saveUnifiedConfig(config);
res.json({ backend });
} catch (error) {
console.error('[cliproxy-server-routes] Failed to save backend config:', error);
res.status(500).json({ error: 'Failed to save backend config' });
}
});
/** /**
* POST /api/cliproxy-server/test - Test remote proxy connection * POST /api/cliproxy-server/test - Test remote proxy connection
*/ */
@@ -0,0 +1,132 @@
/**
* Tests for CLIProxy Backend Selection
* Verifies backend selection feature for original vs plus CLIProxyAPI variants
*/
const assert = require('assert');
describe('Backend Selection', () => {
const platformDetector = require('../../../dist/cliproxy/platform-detector');
const types = require('../../../dist/cliproxy/types');
describe('BACKEND_CONFIG', () => {
it('has correct configuration for original backend', () => {
const config = platformDetector.BACKEND_CONFIG.original;
assert.strictEqual(config.repo, 'router-for-me/CLIProxyAPI');
assert.strictEqual(config.binaryPrefix, 'CLIProxyAPI');
assert.strictEqual(config.executable, 'cli-proxy-api');
assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+$/), 'original version has no suffix');
});
it('has correct configuration for plus backend', () => {
const config = platformDetector.BACKEND_CONFIG.plus;
assert.strictEqual(config.repo, 'router-for-me/CLIProxyAPIPlus');
assert.strictEqual(config.binaryPrefix, 'CLIProxyAPIPlus');
assert.strictEqual(config.executable, 'cli-proxy-api-plus');
assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+-\d+$/), 'plus version has -0 suffix');
});
});
describe('DEFAULT_BACKEND', () => {
it('defaults to plus backend for backward compatibility', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus');
});
});
describe('detectPlatform', () => {
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('generates correct binary name for plus backend', () => {
const info = platformDetector.detectPlatform('6.6.51-0', 'plus');
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
});
it('uses plus backend by default', () => {
const info = platformDetector.detectPlatform();
assert(info.binaryName.includes('CLIProxyAPIPlus'));
});
it('uses fallback version when version not specified', () => {
const infoOriginal = platformDetector.detectPlatform(undefined, 'original');
const fallbackOriginal = platformDetector.BACKEND_CONFIG.original.fallbackVersion;
assert(infoOriginal.binaryName.includes(fallbackOriginal));
const infoPlus = platformDetector.detectPlatform(undefined, 'plus');
const fallbackPlus = platformDetector.BACKEND_CONFIG.plus.fallbackVersion;
assert(infoPlus.binaryName.includes(fallbackPlus));
});
});
describe('getExecutableName', () => {
const isWindows = process.platform === 'win32';
it('returns correct name for original backend', () => {
const name = platformDetector.getExecutableName('original');
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
assert.strictEqual(name, expected);
});
it('returns correct name for plus backend', () => {
const name = platformDetector.getExecutableName('plus');
const expected = isWindows ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus';
assert.strictEqual(name, expected);
});
it('defaults to plus backend', () => {
const name = platformDetector.getExecutableName();
assert(name.includes('cli-proxy-api-plus'));
});
});
describe('getDownloadUrl', () => {
it('uses correct repo for original backend', () => {
const url = platformDetector.getDownloadUrl('6.6.51', 'original');
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
assert(!url.includes('CLIProxyAPIPlus'));
});
it('uses correct repo for plus backend', () => {
const url = platformDetector.getDownloadUrl('6.6.51-0', 'plus');
assert(url.includes('router-for-me/CLIProxyAPIPlus/releases'));
});
it('defaults to plus backend', () => {
const url = platformDetector.getDownloadUrl();
assert(url.includes('CLIProxyAPIPlus'));
});
});
describe('getFallbackVersion', () => {
it('returns correct version for original backend', () => {
const version = platformDetector.getFallbackVersion('original');
assert.strictEqual(version, platformDetector.BACKEND_CONFIG.original.fallbackVersion);
});
it('returns correct version for plus backend', () => {
const version = platformDetector.getFallbackVersion('plus');
assert.strictEqual(version, platformDetector.BACKEND_CONFIG.plus.fallbackVersion);
});
});
describe('PLUS_ONLY_PROVIDERS', () => {
it('includes kiro as plus-only provider', () => {
assert(types.PLUS_ONLY_PROVIDERS.includes('kiro'));
});
it('includes ghcp as plus-only provider', () => {
assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp'));
});
it('does not include gemini as plus-only provider', () => {
assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini'));
});
it('does not include agy as plus-only provider', () => {
assert(!types.PLUS_ONLY_PROVIDERS.includes('agy'));
});
});
});
+8
View File
@@ -495,6 +495,14 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify(config), body: JSON.stringify(config),
}), }),
/** Get backend setting */
getBackend: () => request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend'),
/** Update backend setting */
updateBackend: (backend: 'original' | 'plus', force = false) =>
request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend', {
method: 'PUT',
body: JSON.stringify({ backend, force }),
}),
/** Test remote proxy connection */ /** Test remote proxy connection */
test: (params: { test: (params: {
host: string; host: string;
+124 -3
View File
@@ -3,19 +3,32 @@
* Settings section for CLIProxyAPI configuration (local/remote) * Settings section for CLIProxyAPI configuration (local/remote)
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud, Bug } from 'lucide-react'; import {
RefreshCw,
CheckCircle2,
AlertCircle,
Laptop,
Cloud,
Bug,
Box,
AlertTriangle,
} from 'lucide-react';
import { useProxyConfig, useRawConfig } from '../../hooks'; import { useProxyConfig, useRawConfig } from '../../hooks';
import { LocalProxyCard } from './local-proxy-card'; import { LocalProxyCard } from './local-proxy-card';
import { RemoteProxyCard } from './remote-proxy-card'; import { RemoteProxyCard } from './remote-proxy-card';
import { api } from '@/lib/api-client';
/** LocalStorage key for debug mode preference */ /** LocalStorage key for debug mode preference */
const DEBUG_MODE_KEY = 'ccs_debug_mode'; const DEBUG_MODE_KEY = 'ccs_debug_mode';
/** Providers only available on CLIProxyAPIPlus */
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
export default function ProxySection() { export default function ProxySection() {
const { const {
config, config,
@@ -60,6 +73,52 @@ export default function ProxySection() {
} }
}; };
// Backend state (loaded from API)
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
const [backendSaving, setBackendSaving] = useState(false);
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
// Fetch backend setting
const fetchBackend = useCallback(async () => {
try {
const result = await api.cliproxyServer.getBackend();
setBackend(result.backend);
} catch (err) {
console.error('[Proxy] Failed to fetch backend:', err);
}
}, []);
// Check for Kiro/ghcp variants
const checkPlusOnlyVariants = useCallback(async () => {
try {
const result = await api.cliproxy.list();
const hasIncompatible = result.variants.some((v) => PLUS_ONLY_PROVIDERS.includes(v.provider));
setHasKiroGhcpVariants(hasIncompatible);
} catch (err) {
console.error('[Proxy] Failed to check variants:', err);
}
}, []);
// Save backend setting
const handleBackendChange = async (value: 'original' | 'plus') => {
const previousValue = backend;
setBackend(value);
setBackendSaving(true);
try {
await api.cliproxyServer.updateBackend(value);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to save backend';
// Check if error is due to proxy running (409 conflict)
if (errorMessage.includes('Proxy is running')) {
console.warn('[Proxy] Backend change blocked: proxy is running');
}
console.error('[Proxy] Failed to save backend:', err);
setBackend(previousValue);
} finally {
setBackendSaving(false);
}
};
// Log when debug mode changes (sanitize sensitive fields) // Log when debug mode changes (sanitize sensitive fields)
useEffect(() => { useEffect(() => {
if (debugMode && config) { if (debugMode && config) {
@@ -80,7 +139,9 @@ export default function ProxySection() {
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchRawConfig(); fetchRawConfig();
}, [fetchConfig, fetchRawConfig]); fetchBackend();
checkPlusOnlyVariants();
}, [fetchConfig, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
if (loading || !config) { if (loading || !config) {
return ( return (
@@ -242,6 +303,64 @@ export default function ProxySection() {
</div> </div>
</div> </div>
{/* Backend Selection - Card based selection */}
<div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2">
<Box className="w-4 h-4" />
Backend Binary
</h3>
<div className="grid grid-cols-2 gap-3">
{/* Plus Backend Card */}
<button
onClick={() => handleBackendChange('plus')}
disabled={backendSaving}
className={`p-4 rounded-lg border-2 text-left transition-all ${
backend === 'plus'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPIPlus</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
Default
</span>
</div>
<p className="text-xs text-muted-foreground">
Full provider support including Kiro and GitHub Copilot
</p>
</button>
{/* Original Backend Card */}
<button
onClick={() => handleBackendChange('original')}
disabled={backendSaving}
className={`p-4 rounded-lg border-2 text-left transition-all ${
backend === 'original'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPI</span>
</div>
<p className="text-xs text-muted-foreground">
Original binary (Gemini, Codex, Antigravity only)
</p>
</button>
</div>
{/* Warning when original backend selected with Kiro/ghcp variants */}
{backend === 'original' && hasKiroGhcpVariants && (
<Alert variant="destructive" className="py-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to
CLIProxyAPIPlus or remove those variants.
</AlertDescription>
</Alert>
)}
</div>
{/* Remote Settings - Show when remote mode is enabled */} {/* Remote Settings - Show when remote mode is enabled */}
{isRemoteMode && ( {isRemoteMode && (
<RemoteProxyCard <RemoteProxyCard
@@ -357,6 +476,8 @@ export default function ProxySection() {
onClick={() => { onClick={() => {
fetchConfig(); fetchConfig();
fetchRawConfig(); fetchRawConfig();
fetchBackend();
checkPlusOnlyVariants();
}} }}
disabled={loading || saving} disabled={loading || saving}
className="w-full" className="w-full"