Merge pull request #1064 from kaitranntt/kai/hotfix/1062-cliproxy-plus-deleted

hotfix(cliproxy): fallback from deleted CLIProxyAPIPlus to original backend
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-22 08:50:46 -04:00
committed by GitHub
19 changed files with 370 additions and 112 deletions
+115 -12
View File
@@ -5,10 +5,17 @@
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
import { BinaryInfo, BinaryManagerConfig } from './types';
import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector';
import {
BACKEND_CONFIG,
DEFAULT_BACKEND,
CLIPROXY_MAX_STABLE_VERSION,
getExecutableName,
} from './platform-detector';
import { stopProxy } from './services/proxy-lifecycle-service';
import { waitForPortFree } from '../utils/port-utils';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
@@ -16,6 +23,7 @@ import {
UpdateCheckResult,
checkForUpdates,
deleteBinary,
getVersionCachePath,
getBinaryPath,
isBinaryInstalled,
getBinaryInfo,
@@ -30,11 +38,72 @@ import {
} from './binary';
import type { CLIProxyBackend } from './types';
import { getVersionListCachePath } from './binary/version-cache';
export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062';
/**
* Get backend from config or default to 'plus'
* Track whether we've already warned the user about the Plus fallback this
* process lifetime. Prevents spamming the warning on every command.
*/
function getConfiguredBackend(): CLIProxyBackend {
let plusFallbackWarned = false;
function emitPlusFallbackWarning(): void {
if (plusFallbackWarned) return;
plusFallbackWarned = true;
process.stderr.write(
`${warn(
'CLIProxyAPIPlus upstream repo is currently unavailable; local CLIProxy is falling back to ' +
'`backend: original`. Run `ccs config` to update your saved config. ' +
`Tracking: ${CLIPROXY_PLUS_TRACKING_URL}`
)}\n`
);
}
export function getPlusBackendUnavailableMessage(provider?: string): string {
const prefix = provider
? `${provider} requires CLIProxyAPIPlus,`
: 'CLIProxyAPIPlus upstream repo is currently unavailable,';
return (
`${prefix} but local CLIProxy currently supports only \`backend: original\`. ` +
`Tracking: ${CLIPROXY_PLUS_TRACKING_URL}`
);
}
export function resolveLocalBackend(
backend: CLIProxyBackend = DEFAULT_BACKEND,
options: { warnOnFallback?: boolean } = {}
): CLIProxyBackend {
if (backend !== 'plus') return backend;
if (options.warnOnFallback) {
emitPlusFallbackWarning();
}
return 'original';
}
function copyFallbackStateIfMissing(sourcePath: string, targetPath: string): void {
if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) return;
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
}
export function syncPlusFallbackStateIfNeeded(configuredBackend: CLIProxyBackend): void {
if (configuredBackend !== 'plus') return;
const plusDir = getBackendBinDir('plus');
const originalDir = getBackendBinDir('original');
copyFallbackStateIfMissing(
path.join(plusDir, getExecutableName('plus')),
path.join(originalDir, getExecutableName('original'))
);
copyFallbackStateIfMissing(path.join(plusDir, '.version'), path.join(originalDir, '.version'));
copyFallbackStateIfMissing(getVersionPinPath('plus'), getVersionPinPath('original'));
copyFallbackStateIfMissing(getVersionCachePath('plus'), getVersionCachePath('original'));
copyFallbackStateIfMissing(getVersionListCachePath('plus'), getVersionListCachePath('original'));
}
function getConfiguredOrDefaultBackend(): CLIProxyBackend {
try {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend || DEFAULT_BACKEND;
@@ -43,6 +112,24 @@ function getConfiguredBackend(): CLIProxyBackend {
}
}
export function getStoredConfiguredBackend(): CLIProxyBackend {
return getConfiguredOrDefaultBackend();
}
/**
* Get backend from config, with runtime fallback to 'original' when the user
* still has `backend: plus` saved.
*
* Context (issue #1062): the upstream `router-for-me/CLIProxyAPIPlus` repo was
* deleted, so any `backend: plus` install/update path hits a 404. Rather than
* forcing users to manually edit config.yaml, we degrade to `original` at
* runtime and warn once. This keeps existing installations working without a
* reconfig step, while CCS self-maintains its own Plus fork (future work).
*/
export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend {
return resolveLocalBackend(getConfiguredOrDefaultBackend(), options);
}
/**
* Get backend-specific binary directory.
* Stores binaries in separate dirs: bin/original/ and bin/plus/
@@ -52,7 +139,7 @@ function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return `${baseDir}/${backend}`;
}
/** Default configuration (uses backend from config.yaml or defaults to 'plus') */
/** Default configuration (uses backend from config.yaml or defaults to `DEFAULT_BACKEND`) */
function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig {
const backendConfig = BACKEND_CONFIG[backend];
return {
@@ -76,7 +163,9 @@ export class BinaryManager {
private backend: CLIProxyBackend;
constructor(config: Partial<BinaryManagerConfig> = {}, backend?: CLIProxyBackend) {
this.backend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
this.backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const defaultConfig = createDefaultConfig(this.backend);
this.config = { ...defaultConfig, ...config };
}
@@ -127,7 +216,9 @@ export async function ensureCLIProxyBinary(
verbose = false,
options: EnsureCLIProxyBinaryOptions = {}
): Promise<string> {
const backend = getConfiguredBackend();
const configuredBackend = getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
// Migrate old shared pin to backend-specific location (one-time migration)
migrateVersionPin(backend);
@@ -158,19 +249,25 @@ export async function ensureCLIProxyBinary(
/** Check if CLIProxyAPI binary is installed */
export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return new BinaryManager({}, effectiveBackend).isBinaryInstalled();
}
/** Get CLIProxyAPI binary path (may not exist) */
export function getCLIProxyPath(backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return new BinaryManager({}, effectiveBackend).getBinaryPath();
}
/** Get installed CLIProxyAPI version from .version file */
export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return readInstalledVersion(
getBackendBinDir(effectiveBackend),
BACKEND_CONFIG[effectiveBackend].fallbackVersion
@@ -196,7 +293,9 @@ export async function installCliproxyVersion(
backend?: CLIProxyBackend,
deps: InstallCliproxyVersionDeps = {}
): Promise<void> {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const manager =
deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ??
new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend);
@@ -237,7 +336,9 @@ export async function installCliproxyVersion(
/** Fetch the latest CLIProxyAPI version from GitHub API */
export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise<string> {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const result = await new BinaryManager({}, effectiveBackend).checkForUpdates();
return result.latestVersion;
}
@@ -262,7 +363,9 @@ export interface CliproxyUpdateCheckResult {
export async function checkCliproxyUpdate(
backend?: CLIProxyBackend
): Promise<CliproxyUpdateCheckResult> {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const result = await new BinaryManager({}, effectiveBackend).checkForUpdates();
// Import isNewerVersion for stability check
+21 -19
View File
@@ -18,7 +18,11 @@ import { ProgressIndicator } from '../../utils/progress-indicator';
import { ok, fail, info, warn } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
import { ensureCLIProxyBinary } from '../binary-manager';
import {
ensureCLIProxyBinary,
getConfiguredBackend,
getPlusBackendUnavailableMessage,
} from '../binary-manager';
import {
generateConfig,
getProviderConfig,
@@ -30,7 +34,6 @@ import {
import { checkRemoteProxy } from '../remote-proxy-client';
import { isAuthenticated } from '../auth-handler';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
import { DEFAULT_BACKEND } from '../platform-detector';
import { configureProviderModel, getCurrentModel } from '../model-config';
import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver';
@@ -207,23 +210,8 @@ export async function execClaudeWithCLIProxy(
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
const unifiedConfig = loadOrCreateUnifiedConfig();
// 0a. Runtime backend/provider validation
const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND;
// Collect all providers to validate (default + composite tiers)
const allProviders = [provider, ...compositeProviders];
for (const p of allProviders) {
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
console.error('');
console.error(fail(`${p} 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 ' + p + ' --backend=plus');
console.error('');
throw new Error(`Provider ${p} requires Plus backend`);
}
}
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
@@ -283,6 +271,7 @@ export async function execClaudeWithCLIProxy(
// Check remote proxy if configured
let useRemoteProxy = false;
let localBackend: CLIProxyBackend = 'original';
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
const status = await checkRemoteProxy({
host: proxyConfig.host,
@@ -330,6 +319,19 @@ export async function execClaudeWithCLIProxy(
}
}
if (!useRemoteProxy) {
localBackend = getConfiguredBackend({ warnOnFallback: true });
for (const p of allProviders) {
if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
console.error('');
console.error(fail(getPlusBackendUnavailableMessage(p)));
console.error('');
throw new Error(`Provider ${p} is temporarily unavailable on local CLIProxy`);
}
}
}
// Variables for local proxy mode
let binaryPath: string | undefined;
let sessionId: string | undefined;
@@ -969,13 +971,13 @@ export async function execClaudeWithCLIProxy(
cfg.port,
cfg.timeout,
cfg.pollInterval,
backend,
localBackend,
configPath
);
// Register session
if (proxy.pid) {
sessionId = registerProxySession(cfg.port, proxy.pid, backend, verbose);
sessionId = registerProxySession(cfg.port, proxy.pid, localBackend, verbose);
}
}
}
+9 -2
View File
@@ -29,8 +29,15 @@ export const BACKEND_CONFIG = {
},
} as const;
/** Default backend */
export const DEFAULT_BACKEND: CLIProxyBackend = 'plus';
/**
* Default backend
*
* Set to 'original' because upstream `router-for-me/CLIProxyAPIPlus` was
* deleted (issue #1062). The original `router-for-me/CLIProxyAPI` repo is
* still maintained. Users with existing `backend: plus` configs are migrated
* at runtime via a 404 fallback in the installer (see binary/installer.ts).
*/
export const DEFAULT_BACKEND: CLIProxyBackend = 'original';
/**
* CLIProxyAPIPlus fallback version (used when GitHub API unavailable)
+23 -7
View File
@@ -18,6 +18,8 @@ import {
savePinnedVersion,
clearPinnedVersion,
isVersionPinned,
resolveLocalBackend,
syncPlusFallbackStateIfNeeded,
} from '../binary-manager';
import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector';
import { CLIProxyBackend } from '../types';
@@ -54,8 +56,10 @@ export interface LatestVersionResult {
* Get current binary status for a specific backend
*/
export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const backendConfig = BACKEND_CONFIG[effectiveBackend];
return {
installed: isCLIProxyInstalled(effectiveBackend),
@@ -71,8 +75,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
* Check for latest version
*/
export async function checkLatestVersion(backend?: CLIProxyBackend): Promise<LatestVersionResult> {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
try {
// Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo)
@@ -119,8 +125,10 @@ export async function installVersion(
};
}
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
try {
await installCliproxyVersion(version, verbose, effectiveBackend);
@@ -147,8 +155,10 @@ export async function installLatest(
verbose = false,
backend?: CLIProxyBackend
): Promise<InstallResult> {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
try {
const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend);
@@ -184,8 +194,10 @@ export async function installLatest(
* Check if a version is pinned
*/
export function isPinned(backend?: CLIProxyBackend): boolean {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return isVersionPinned(effectiveBackend);
}
@@ -193,8 +205,10 @@ export function isPinned(backend?: CLIProxyBackend): boolean {
* Get pinned version if any
*/
export function getPinned(backend?: CLIProxyBackend): string | null {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return getPinnedVersion(effectiveBackend);
}
@@ -202,7 +216,9 @@ export function getPinned(backend?: CLIProxyBackend): string | null {
* Clear version pin
*/
export function clearPin(backend?: CLIProxyBackend): void {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
clearPinnedVersion(effectiveBackend);
}
+13 -16
View File
@@ -8,12 +8,10 @@
import * as os from 'os';
import * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
import { CLIProxyProvider, PLUS_ONLY_PROVIDERS } from '../types';
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
import type { TargetType } from '../../targets/target-adapter';
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 { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
@@ -44,6 +42,7 @@ import {
removeVariantFromLegacyConfig,
getNextAvailablePort,
} from './variant-config-adapter';
import { getConfiguredBackend, getPlusBackendUnavailableMessage } from '../binary-manager';
// Re-export VariantConfig from adapter
export type { VariantConfig } from './variant-config-adapter';
@@ -80,16 +79,15 @@ export function validateProfileName(name: string): string | null {
/**
* Validate provider/backend compatibility
* Returns error message if provider requires Plus backend but original is configured
* Returns error message if a provider requires Plus while local CLIProxy is
* running with the fallbacked original backend.
*/
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;
const backend = getConfiguredBackend();
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(normalizedProvider)) {
return `${provider} requires CLIProxyAPIPlus. Set \`cliproxy.backend: plus\` in config.yaml or use --backend=plus`;
return getPlusBackendUnavailableMessage(provider);
}
return null;
}
@@ -280,6 +278,13 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
}
}
if (updates.provider !== undefined) {
const backendError = validateProviderBackend(updates.provider);
if (backendError) {
return { success: false, error: backendError };
}
}
// Update settings file
if (existing.settings) {
const settingsPath = existing.settings.replace(/^~/, os.homedir());
@@ -302,14 +307,6 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
// Update config entry if provider/account/target changed
if (updates.provider !== undefined || updates.account !== undefined || targetChanged) {
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 newTarget = updates.target ?? existingTarget;
+4 -2
View File
@@ -148,8 +148,10 @@ export type CLIProxyProvider =
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (legacy provider subset)
* - plus: CLIProxyAPIPlus (expanded provider support, default)
* - original: CLIProxyAPI (current default; upstream still maintained)
* - plus: CLIProxyAPIPlus (expanded provider support; upstream deleted as of
* issue #1062 — runtime falls back to `original`. Retained for forward
* compatibility once CCS self-maintains its own Plus build.)
*/
export type CLIProxyBackend = 'original' | 'plus';
+9 -1
View File
@@ -84,7 +84,10 @@ export async function showHelp(): Promise<void> {
[
'Options:',
[
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
[
'--backend <type>',
'Use specific backend: original | plus (local default: original; plus currently falls back locally)',
],
['--target <cli>', 'Default target for created/edited variants: claude | droid'],
['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'],
],
@@ -102,6 +105,11 @@ export async function showHelp(): Promise<void> {
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
console.log(dim(' Routing: use gcli/<model> or agy/<model> to keep overlapping models pinned.'));
console.log(
dim(
' Backend: local CLIProxy currently uses original by default; saved plus configs fall back locally.'
)
);
console.log('');
console.log(subheader('Notes:'));
console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`);
+3 -6
View File
@@ -6,14 +6,13 @@
*/
import { CLIProxyBackend } from '../../cliproxy/types';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager';
import {
type QuotaSupportedProvider,
QUOTA_PROVIDER_HELP_TEXT,
mapExternalProviderName,
isQuotaSupportedProvider,
} from '../../cliproxy/provider-capabilities';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { handleSync } from '../cliproxy-sync-handler';
import { extractOption, hasAnyFlag } from '../arg-extractor';
@@ -71,12 +70,10 @@ function parseBackendArg(args: string[]): {
}
/**
* Get effective backend (CLI flag > config.yaml > default)
* Get selected backend input (CLI flag > config.yaml > default)
*/
function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
if (cliBackend) return cliBackend;
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend ?? DEFAULT_BACKEND;
return cliBackend ?? getStoredConfiguredBackend();
}
/**
+1 -1
View File
@@ -47,7 +47,7 @@ export async function showStatus(verbose: boolean, backend: CLIProxyBackend): Pr
console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`);
}
const latestCheck = await checkLatestVersion();
const latestCheck = await checkLatestVersion(backend);
if (latestCheck.success && latestCheck.latestVersion) {
console.log('');
if (latestCheck.updateAvailable) {
+1 -1
View File
@@ -402,7 +402,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
: undefined, // Invalid values become undefined (defaults to 'original' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
+2 -2
View File
@@ -210,7 +210,7 @@ export interface CLIProxyRoutingConfig {
* CLIProxy configuration section.
*/
export interface CLIProxyConfig {
/** Backend selection: 'original' or 'plus' (default: 'plus') */
/** Backend selection: 'original' or 'plus' (default: 'original') */
backend?: 'original' | 'plus';
/** Nickname to email mapping for OAuth accounts */
oauth_accounts: OAuthAccounts;
@@ -1001,7 +1001,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
accounts: {},
profiles: {},
cliproxy: {
backend: 'plus',
backend: 'original',
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
+12 -20
View File
@@ -38,7 +38,11 @@ import {
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
import { ensureCliproxyService } from '../../cliproxy/service-manager';
import { checkCliproxyUpdate, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
import {
checkCliproxyUpdate,
getInstalledCliproxyVersion,
getStoredConfiguredBackend,
} from '../../cliproxy/binary-manager';
import {
fetchAllVersions,
isNewerVersion,
@@ -47,9 +51,7 @@ import {
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
DEFAULT_BACKEND,
} from '../../cliproxy/platform-detector';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import {
MODEL_ENV_VAR_KEYS,
@@ -143,18 +145,8 @@ export function shouldCacheQuotaResult(result: {
return !transientPatterns.some((p) => msg.includes(p));
}
/** Get configured backend from config */
function getConfiguredBackend() {
try {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend || DEFAULT_BACKEND;
} catch {
return DEFAULT_BACKEND;
}
}
function buildUpdateCheckFallback(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
@@ -178,7 +170,7 @@ function buildUpdateCheckFallback(
}
function buildVersionsFallback(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
@@ -206,7 +198,7 @@ interface ResolveVersionsDeps {
}
export async function resolveCliproxyUpdateCheckPayload(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
deps: ResolveUpdateCheckDeps = {}
) {
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
@@ -218,7 +210,7 @@ export async function resolveCliproxyUpdateCheckPayload(
}
export async function resolveCliproxyVersionsPayload(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
deps: ResolveVersionsDeps = {}
) {
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
@@ -409,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
*/
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getConfiguredBackend();
const backend = getStoredConfiguredBackend();
const result = await resolveCliproxyUpdateCheckPayload(backend);
res.json(result);
@@ -1019,7 +1011,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
*/
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getConfiguredBackend();
const backend = getStoredConfiguredBackend();
res.json(await resolveCliproxyVersionsPayload(backend));
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
@@ -1073,7 +1065,7 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
return;
}
const backend = getConfiguredBackend();
const backend = getStoredConfiguredBackend();
const installResult = await installDashboardCliproxyVersion(version, backend);
res.json({
@@ -1,4 +1,8 @@
import { installCliproxyVersion } from '../../cliproxy/binary-manager';
import {
installCliproxyVersion,
resolveLocalBackend,
syncPlusFallbackStateIfNeeded,
} from '../../cliproxy/binary-manager';
import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager';
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
@@ -52,12 +56,14 @@ export async function installDashboardCliproxyVersion(
backend: CLIProxyBackend,
deps: InstallDashboardCliproxyVersionDeps = defaultDeps
): Promise<DashboardCliproxyInstallResult> {
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
syncPlusFallbackStateIfNeeded(backend);
const effectiveBackend = resolveLocalBackend(backend, { warnOnFallback: true });
const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
const shouldRestoreService = await wasProxyRunning(deps);
// The installer owns the stop-and-replace lifecycle, including best-effort
// shutdown for tracked and untracked proxies before swapping the binary.
await deps.installCliproxyVersion(version, true, backend);
await deps.installCliproxyVersion(version, true, effectiveBackend);
if (!shouldRestoreService) {
return {
+11 -8
View File
@@ -28,8 +28,8 @@ describe('Backend Selection', () => {
});
describe('DEFAULT_BACKEND', () => {
it('defaults to plus backend for backward compatibility', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus');
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original');
});
});
@@ -45,9 +45,10 @@ describe('Backend Selection', () => {
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
});
it('uses plus backend by default', () => {
it('uses original backend by default (Plus upstream deleted, issue #1062)', () => {
const info = platformDetector.detectPlatform();
assert(info.binaryName.includes('CLIProxyAPIPlus'));
assert(info.binaryName.startsWith('CLIProxyAPI_'));
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
});
it('uses fallback version when version not specified', () => {
@@ -76,9 +77,10 @@ describe('Backend Selection', () => {
assert.strictEqual(name, expected);
});
it('defaults to plus backend', () => {
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
const name = platformDetector.getExecutableName();
assert(name.includes('cli-proxy-api-plus'));
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
assert.strictEqual(name, expected);
});
});
@@ -94,9 +96,10 @@ describe('Backend Selection', () => {
assert(url.includes('router-for-me/CLIProxyAPIPlus/releases'));
});
it('defaults to plus backend', () => {
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
const url = platformDetector.getDownloadUrl();
assert(url.includes('CLIProxyAPIPlus'));
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
assert(!url.includes('CLIProxyAPIPlus'));
});
});
@@ -25,6 +25,80 @@ afterEach(() => {
});
describe('installCliproxyVersion', () => {
it('degrades explicit plus backend requests to original before install flows run', async () => {
let seenBackend: string | undefined;
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-explicit-plus=${Date.now()}`
);
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
createManager: (_config: unknown, backend: string) => {
seenBackend = backend;
return {
isBinaryInstalled: () => false,
deleteBinary: () => undefined,
ensureBinary: async () => '/tmp/ccs-bin/original/cliproxy',
};
},
stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }),
waitForPortFreeFn: async () => true,
formatInfo: (message: string) => message,
formatWarn: (message: string) => message,
getInstalledVersion: () => '6.6.80',
});
expect(seenBackend).toBe('original');
});
it('returns original and emits a real warning when plus backend is resolved locally', async () => {
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-warning=${Date.now()}`
);
const writes: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
return true;
}) as typeof process.stderr.write;
try {
expect(binaryManager.resolveLocalBackend('plus', { warnOnFallback: true })).toBe('original');
} finally {
process.stderr.write = originalWrite;
}
expect(writes.join('')).toContain('CLIProxyAPIPlus upstream repo is currently unavailable');
expect(writes.join('')).toContain('backend: original');
});
it('reuses plus binary and pin state when local runtime falls back to original', async () => {
const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types');
const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader');
const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache');
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
const binaryService = await import(
`../../../src/cliproxy/services/binary-service?binary-service-plus-migration=${Date.now()}`
);
const config = createEmptyUnifiedConfig();
config.cliproxy = { ...config.cliproxy, backend: 'plus' };
saveUnifiedConfig(config);
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
fs.mkdirSync(plusBinDir, { recursive: true });
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'fake-binary');
fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0');
savePinnedVersion('6.6.80-0', 'plus');
const status = binaryService.getBinaryStatus();
expect(status.installed).toBe(true);
expect(status.pinnedVersion).toBe('6.6.80-0');
expect(status.binaryPath).toContain('/original/');
});
it('attempts to stop the proxy even when there is no tracked running session', async () => {
const calls = {
stopProxy: 0,
@@ -78,7 +152,7 @@ describe('installCliproxyVersion', () => {
skipAutoUpdate: true,
})
).rejects.toThrow(
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
'CLIProxy binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
);
});
});
@@ -6,7 +6,10 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { updateVariant } from '../../../src/cliproxy/services/variant-service';
import {
updateVariant,
validateProviderBackend,
} from '../../../src/cliproxy/services/variant-service';
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
describe('updateVariant - provider/model consistency', () => {
@@ -50,6 +53,7 @@ preferences:
telemetry: false
auto_update: true
cliproxy:
backend: plus
oauth_accounts: {}
providers:
- gemini
@@ -92,6 +96,25 @@ cliproxy:
expect(result.error).toContain('denylist');
});
it('reports plus-only providers as temporarily unavailable on local CLIProxy', () => {
const error = validateProviderBackend('ghcp');
expect(error).toContain('currently supports only `backend: original`');
expect(error).toContain('issues/1062');
});
it('leaves the settings file unchanged when a plus-only provider update is rejected', () => {
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
const before = fs.readFileSync(settingsPath, 'utf-8');
const result = updateVariant('demo', {
provider: 'ghcp',
model: 'gpt-5.4-mini',
});
expect(result.success).toBe(false);
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
});
it('updates provider and regenerates provider-specific core env in same settings file', () => {
const result = updateVariant('demo', {
provider: 'codex',
@@ -56,7 +56,7 @@ describe('installDashboardCliproxyVersion', () => {
success: true,
restarted: true,
port: 8317,
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
message: 'Successfully installed CLIProxy v6.7.1 and restarted it on port 8317',
});
expect(calls.isCliproxyRunning).toBe(0);
expect(calls.installCliproxyVersion).toBe(1);
@@ -71,7 +71,7 @@ describe('installDashboardCliproxyVersion', () => {
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: true,
restarted: false,
message: 'Successfully installed CLIProxy Plus v6.7.1',
message: 'Successfully installed CLIProxy v6.7.1',
});
expect(calls.isCliproxyRunning).toBe(1);
expect(calls.installCliproxyVersion).toBe(1);
@@ -118,8 +118,8 @@ describe('installDashboardCliproxyVersion', () => {
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: false,
restarted: false,
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
error: 'Installed CLIProxy v6.7.1, but restart failed',
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
});
});
});
@@ -10,6 +10,7 @@ let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir;
let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion;
let writeVersionCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionCache;
let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache;
let server: Server;
@@ -25,7 +26,7 @@ beforeAll(async () => {
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
({ writeInstalledVersion, writeVersionListCache } = await import(
({ writeInstalledVersion, writeVersionCache, writeVersionListCache } = await import(
'../../../src/cliproxy/binary/version-cache'
));
@@ -39,6 +40,7 @@ beforeAll(async () => {
saveUnifiedConfig(config);
writeInstalledVersion(plusBinDir, '6.6.80');
writeVersionCache('6.6.89', 'plus');
writeVersionListCache(
{
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
@@ -94,6 +96,23 @@ afterAll(async () => {
});
describe('cliproxy-stats-routes install contract', () => {
it('routes saved plus configs through original backend for update checks', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/update-check`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
backend: string;
backendLabel: string;
currentVersion: string;
latestVersion: string;
};
expect(body.backend).toBe('original');
expect(body.backendLabel).toBe('CLIProxy');
expect(body.currentVersion).toBe('6.6.80');
expect(body.latestVersion).toBe('6.6.89');
});
it('returns faultyRange in the versions response', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
expect(response.status).toBe(200);
+14 -5
View File
@@ -36,7 +36,7 @@ import { useTranslation } from 'react-i18next';
const DEBUG_MODE_KEY = 'ccs_debug_mode';
/** Providers only available on CLIProxyAPIPlus */
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp', 'cursor', 'gitlab', 'codebuddy', 'kilo'];
function normalizeRiskAckPhrase(value: string): string {
return value.trim().replace(/\s+/g, ' ').toUpperCase();
@@ -191,7 +191,7 @@ export default function ProxySection() {
}, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
// Backend state (loaded from API) + mutation hook for proper query invalidation
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
const [backend, setBackend] = useState<'original' | 'plus'>('original');
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
const updateBackendMutation = useUpdateBackend();
const { data: proxyStatus } = useProxyStatus();
@@ -490,9 +490,6 @@ export default function ProxySection() {
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</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">
{t('settingsProxy.default')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
</button>
@@ -509,10 +506,22 @@ export default function ProxySection() {
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{t('settingsProxy.backendApi')}</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">
{t('settingsProxy.default')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
</button>
</div>
{backend === 'plus' && (
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400">
CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the
original backend until issue #1062 is resolved.
</AlertDescription>
</Alert>
)}
{/* Warning when original backend selected with Kiro/ghcp variants */}
{backend === 'original' && hasKiroGhcpVariants && (
<Alert variant="destructive" className="py-2">