Merge pull request #347 from kaitranntt/feat/cliproxy-backend-selection

feat(cliproxy): add backend selection for CLIProxyAPI vs CLIProxyAPIPlus
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-18 10:28:01 -05:00
committed by GitHub
12 changed files with 617 additions and 74 deletions
+64 -24
View File
@@ -8,9 +8,10 @@
import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
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 { waitForPortFree } from '../utils/port-utils';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import {
UpdateCheckResult,
checkForUpdates,
@@ -27,24 +28,53 @@ import {
ensureBinary,
} from './binary';
/** Default configuration (uses CLIProxyAPIPlus fork with Kiro + Copilot support) */
const DEFAULT_CONFIG: BinaryManagerConfig = {
version: CLIPROXY_FALLBACK_VERSION,
releaseUrl: 'https://github.com/router-for-me/CLIProxyAPIPlus/releases/download',
binPath: getBinDir(),
maxRetries: 3,
verbose: false,
forceVersion: false,
};
type CLIProxyBackend = 'original' | 'plus';
/**
* Get backend from config or default to 'plus'
*/
function getConfiguredBackend(): CLIProxyBackend {
try {
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
*/
export class BinaryManager {
private config: BinaryManagerConfig;
private backend: CLIProxyBackend;
constructor(config: Partial<BinaryManagerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
constructor(config: Partial<BinaryManagerConfig> = {}, backend?: CLIProxyBackend) {
this.backend = backend ?? getConfiguredBackend();
const defaultConfig = createDefaultConfig(this.backend);
this.config = { ...defaultConfig, ...config };
}
/** Ensure binary is available (download if missing, update if outdated) */
@@ -80,36 +110,44 @@ export class BinaryManager {
/** Convenience function respecting version pin */
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
const backend = getConfiguredBackend();
const pinnedVersion = getPinnedVersion();
if (pinnedVersion) {
if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`);
return new BinaryManager({
version: pinnedVersion,
verbose,
forceVersion: true,
}).ensureBinary();
return new BinaryManager(
{
version: pinnedVersion,
verbose,
forceVersion: true,
},
backend
).ensureBinary();
}
return new BinaryManager({ verbose }).ensureBinary();
return new BinaryManager({ verbose }, backend).ensureBinary();
}
/** Check if CLIProxyAPI binary is installed */
export function isCLIProxyInstalled(): boolean {
return new BinaryManager().isBinaryInstalled();
const backend = getConfiguredBackend();
return new BinaryManager({}, backend).isBinaryInstalled();
}
/** Get CLIProxyAPI binary path (may not exist) */
export function getCLIProxyPath(): string {
return new BinaryManager().getBinaryPath();
const backend = getConfiguredBackend();
return new BinaryManager({}, backend).getBinaryPath();
}
/** Get installed CLIProxyAPI version from .version file */
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 */
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
if (isProxyRunning()) {
@@ -140,7 +178,8 @@ export async function installCliproxyVersion(version: string, verbose = false):
/** Fetch the latest CLIProxyAPI version from GitHub API */
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;
}
@@ -159,7 +198,8 @@ export interface CliproxyUpdateCheckResult {
/** Check for CLIProxyAPI binary updates */
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
const { isNewerVersion } = await import('./binary/version-checker');
+16 -1
View File
@@ -30,7 +30,8 @@ import {
} from './config-generator';
import { checkRemoteProxy } from './remote-proxy-client';
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 { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
@@ -141,6 +142,20 @@ export async function execClaudeWithCLIProxy(
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
// This filters proxy flags from args and returns resolved config
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 { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
remote: cliproxyServerConfig?.remote
+73 -20
View File
@@ -5,14 +5,40 @@
* Supports 6 platforms: darwin/linux/windows x amd64/arm64
*/
import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types';
import {
PlatformInfo,
SupportedOS,
SupportedArch,
ArchiveExtension,
CLIProxyBackend,
} from './types';
/** 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)
* Auto-update fetches latest from GitHub; this is only a safety net
* 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
@@ -48,10 +74,14 @@ const ARCH_MAP: Record<string, SupportedArch | undefined> = {
/**
* 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
*/
export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): PlatformInfo {
export function detectPlatform(
version?: string,
backend: CLIProxyBackend = DEFAULT_BACKEND
): PlatformInfo {
const nodePlatform = process.platform;
const nodeArch = process.arch;
@@ -71,8 +101,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 binaryName = `CLIProxyAPIPlus_${version}_${os}_${arch}.${extension}`;
const binaryName = `${config.binaryPrefix}_${ver}_${os}_${arch}.${extension}`;
return {
os,
@@ -84,41 +116,62 @@ export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): Pla
/**
* Get executable name based on platform
* @param backend Backend variant to use (defaults to DEFAULT_BACKEND)
* @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 {
const platform = detectPlatform();
return platform.os === 'windows' ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus';
export function getExecutableName(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
const config = BACKEND_CONFIG[backend];
const platform = detectPlatform(undefined, backend);
return platform.os === 'windows' ? `${config.executable}.exe` : config.executable;
}
/**
* 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
*/
export function getArchiveBinaryName(): string {
const platform = detectPlatform();
return platform.os === 'windows' ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus';
export function getArchiveBinaryName(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return getExecutableName(backend);
}
/**
* 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
*/
export function getDownloadUrl(version: string = CLIPROXY_FALLBACK_VERSION): string {
const platform = detectPlatform(version);
const baseUrl = `https://github.com/router-for-me/CLIProxyAPIPlus/releases/download/v${version}`;
return `${baseUrl}/${platform.binaryName}`;
export function getDownloadUrl(
version?: string,
backend: CLIProxyBackend = DEFAULT_BACKEND
): 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
* @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
*/
export function getChecksumsUrl(version: string = CLIPROXY_FALLBACK_VERSION): string {
return `https://github.com/router-for-me/CLIProxyAPIPlus/releases/download/v${version}/checksums.txt`;
export function getChecksumsUrl(
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,
isVersionPinned,
} 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 */
export interface BinaryStatusResult {
@@ -28,6 +30,7 @@ export interface BinaryStatusResult {
pinnedVersion: string | null;
binaryPath: string;
fallbackVersion: string;
backend: CLIProxyBackend;
}
/** 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 {
installed: isCLIProxyInstalled(),
currentVersion: getInstalledCliproxyVersion(),
pinnedVersion: getPinnedVersion(),
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 path from 'path';
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 { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { DEFAULT_BACKEND } from '../platform-detector';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config-generator';
import { deleteSessionLockForPort } from '../session-tracker';
@@ -64,6 +66,22 @@ export function validateProfileName(name: string): string | 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
*/
@@ -88,6 +106,12 @@ export function createVariant(
account?: string
): VariantOperationResult {
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
const port = getNextAvailablePort();
@@ -188,6 +212,14 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
// Update config entry if provider or account changed
if (updates.provider !== undefined || updates.account !== undefined) {
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;
if (isUnifiedMode()) {
+12
View File
@@ -119,6 +119,18 @@ export interface DownloadResult {
*/
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)
*/
+78 -21
View File
@@ -29,11 +29,11 @@ import {
} from '../cliproxy/account-manager';
import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher';
import { isOnCooldown } from '../cliproxy/quota-manager';
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
import { DEFAULT_BACKEND, getFallbackVersion, BACKEND_CONFIG } from '../cliproxy/platform-detector';
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog';
import { CLIProxyProvider } from '../cliproxy/types';
import { isUnifiedMode } from '../config/unified-config-loader';
import { CLIProxyProvider, CLIProxyBackend } from '../cliproxy/types';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import {
initUI,
header,
@@ -68,6 +68,49 @@ import {
// 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') {
warn(`Invalid backend '${value}'. Valid options: original, 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') {
warn(`Invalid backend '${value}'. Valid options: original, 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 {
name?: string;
provider?: CLIProxyProfileName;
@@ -431,14 +474,18 @@ async function handleProxyStatus(): Promise<void> {
console.log('');
}
async function showStatus(verbose: boolean): Promise<void> {
async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise<void> {
await initUI();
const status = getBinaryStatus();
const status = getBinaryStatus(backend);
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(
` Backend: ${color(backend, 'info')}${backend === DEFAULT_BACKEND ? dim(' (default)') : ''}`
);
if (status.installed) {
console.log(` Installed: ${color('Yes', 'success')}`);
const versionLabel = status.pinnedVersion
@@ -576,7 +623,13 @@ async function showHelp(): Promise<void> {
['--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) {
@@ -591,9 +644,9 @@ async function showHelp(): Promise<void> {
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
console.log('');
console.log(subheader('Notes:'));
console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`);
console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`);
console.log(
` Releases: ${color('https://github.com/router-for-me/CLIProxyAPIPlus/releases', 'path')}`
` Releases: ${color(`https://github.com/${BACKEND_CONFIG[DEFAULT_BACKEND].repo}/releases`, 'path')}`
);
console.log('');
}
@@ -909,16 +962,20 @@ async function handleQuotaStatus(verbose = false): Promise<void> {
// ============================================================================
export async function handleCliproxyCommand(args: string[]): Promise<void> {
const verbose = args.includes('--verbose') || args.includes('-v');
const command = args[0];
// Parse --backend flag first (before other processing)
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();
return;
}
if (command === 'create') {
await handleCreate(args.slice(1));
await handleCreate(remainingArgs.slice(1));
return;
}
@@ -928,7 +985,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
}
if (command === 'remove' || command === 'delete' || command === 'rm') {
await handleRemove(args.slice(1));
await handleRemove(remainingArgs.slice(1));
return;
}
@@ -949,17 +1006,17 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
// Quota management commands
if (command === 'default') {
await handleSetDefault(args.slice(1));
await handleSetDefault(remainingArgs.slice(1));
return;
}
if (command === 'pause') {
await handlePauseAccount(args.slice(1));
await handlePauseAccount(remainingArgs.slice(1));
return;
}
if (command === 'resume') {
await handleResumeAccount(args.slice(1));
await handleResumeAccount(remainingArgs.slice(1));
return;
}
@@ -968,9 +1025,9 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
const installIdx = args.indexOf('--install');
const installIdx = remainingArgs.indexOf('--install');
if (installIdx !== -1) {
let version = args[installIdx + 1];
let version = remainingArgs[installIdx + 1];
if (!version || version.startsWith('-')) {
console.error(fail('Missing version argument for --install'));
console.error(' Usage: ccs cliproxy --install <version>');
@@ -983,10 +1040,10 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
if (args.includes('--latest') || args.includes('--update')) {
if (remainingArgs.includes('--latest') || remainingArgs.includes('--update')) {
await handleInstallLatest(verbose);
return;
}
await showStatus(verbose);
await showStatus(verbose, effectiveBackend);
}
+3
View File
@@ -115,6 +115,8 @@ export interface TokenRefreshSettings {
* CLIProxy configuration section.
*/
export interface CLIProxyConfig {
/** Backend selection: 'original' or 'plus' (default: 'plus') */
backend?: 'original' | 'plus';
/** Nickname to email mapping for OAuth accounts */
oauth_accounts: OAuthAccounts;
/** Built-in providers (read-only, for reference) */
@@ -530,6 +532,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
accounts: {},
profiles: {},
cliproxy: {
backend: 'plus',
oauth_accounts: {},
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'],
variants: {},
+62
View File
@@ -10,6 +10,7 @@
import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
import { testConnection } from '../../cliproxy/remote-proxy-client';
import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service';
import {
DEFAULT_CLIPROXY_SERVER_CONFIG,
CliproxyServerConfig,
@@ -65,6 +66,67 @@ router.put('/', async (req: Request, res: Response) => {
}
});
/**
* GET /api/cliproxy-server/backend - Get CLIProxy backend setting
* @returns {{ backend: 'original' | 'plus' }} Current backend configuration
*/
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
* @param {Object} req.body - Request body
* @param {'original' | 'plus'} req.body.backend - Backend to switch to
* @param {boolean} [req.body.force=false] - Force change even if proxy is running
* @returns {{ backend: 'original' | 'plus' }} Updated backend configuration
* @throws {400} Invalid backend value
* @throws {409} Proxy is running (unless force=true)
*/
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
*/
@@ -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',
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: (params: {
host: string;
+125 -3
View File
@@ -3,19 +3,33 @@
* 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 { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
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 { toast } from 'sonner';
import { useProxyConfig, useRawConfig } from '../../hooks';
import { LocalProxyCard } from './local-proxy-card';
import { RemoteProxyCard } from './remote-proxy-card';
import { api } from '@/lib/api-client';
/** LocalStorage key for debug mode preference */
const DEBUG_MODE_KEY = 'ccs_debug_mode';
/** Providers only available on CLIProxyAPIPlus */
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
export default function ProxySection() {
const {
config,
@@ -60,6 +74,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')) {
toast.error('Stop the proxy first to change backend');
}
console.error('[Proxy] Failed to save backend:', err);
setBackend(previousValue);
} finally {
setBackendSaving(false);
}
};
// Log when debug mode changes (sanitize sensitive fields)
useEffect(() => {
if (debugMode && config) {
@@ -80,7 +140,9 @@ export default function ProxySection() {
useEffect(() => {
fetchConfig();
fetchRawConfig();
}, [fetchConfig, fetchRawConfig]);
fetchBackend();
checkPlusOnlyVariants();
}, [fetchConfig, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
if (loading || !config) {
return (
@@ -242,6 +304,64 @@ export default function ProxySection() {
</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 */}
{isRemoteMode && (
<RemoteProxyCard
@@ -357,6 +477,8 @@ export default function ProxySection() {
onClick={() => {
fetchConfig();
fetchRawConfig();
fetchBackend();
checkPlusOnlyVariants();
}}
disabled={loading || saving}
className="w-full"