From dd90ea7e2fa4dcac6227009caacc1f76711c79b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 08:35:26 -0400 Subject: [PATCH] hotfix: close cliproxy plus fallback gaps --- src/cliproxy/binary-manager.ts | 81 ++++++++++++++----- src/cliproxy/executor/index.ts | 40 ++++----- src/cliproxy/services/binary-service.ts | 43 ++++++---- src/cliproxy/services/variant-service.ts | 14 ++-- src/commands/cliproxy/index.ts | 10 +-- src/commands/cliproxy/install-subcommand.ts | 2 +- src/config/unified-config-loader.ts | 2 +- src/config/unified-config-types.ts | 2 +- .../routes/cliproxy-stats-routes.ts | 24 ++---- .../cliproxy-dashboard-install-service.ts | 7 +- .../cliproxy/binary-manager-install.test.ts | 48 +++++++++++ .../cliproxy/variant-update-service.test.ts | 12 ++- ...cliproxy-dashboard-install-service.test.ts | 8 +- .../cliproxy-stats-routes-install.test.ts | 29 +++++-- .../pages/settings/sections/proxy/index.tsx | 17 +++- 15 files changed, 235 insertions(+), 104 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 1a4237aa..5f507e53 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -31,12 +31,47 @@ import { import type { CLIProxyBackend } from './types'; +export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; + /** * Track whether we've already warned the user about the Plus fallback this * process lifetime. Prevents spamming the warning on every command. */ 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'; +} + /** * Get backend from config, with runtime fallback to 'original' when the user * still has `backend: plus` saved. @@ -47,7 +82,7 @@ let plusFallbackWarned = false; * runtime and warn once. This keeps existing installations working without a * reconfig step, while CCS self-maintains its own Plus fork (future work). */ -function getConfiguredBackend(): CLIProxyBackend { +export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend { let configured: CLIProxyBackend; try { const config = loadOrCreateUnifiedConfig(); @@ -56,19 +91,7 @@ function getConfiguredBackend(): CLIProxyBackend { return DEFAULT_BACKEND; } - if (configured === 'plus') { - if (!plusFallbackWarned) { - plusFallbackWarned = true; - warn( - 'CLIProxyAPIPlus upstream repo is currently unavailable; falling back to ' + - '`backend: original`. Run `ccs config` to update your saved config. ' + - 'Tracking: https://github.com/kaitranntt/ccs/issues/1062' - ); - } - return 'original'; - } - - return configured; + return resolveLocalBackend(configured, options); } /** @@ -104,7 +127,9 @@ export class BinaryManager { private backend: CLIProxyBackend; constructor(config: Partial = {}, backend?: CLIProxyBackend) { - this.backend = backend ?? getConfiguredBackend(); + this.backend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const defaultConfig = createDefaultConfig(this.backend); this.config = { ...defaultConfig, ...config }; } @@ -155,7 +180,7 @@ export async function ensureCLIProxyBinary( verbose = false, options: EnsureCLIProxyBinaryOptions = {} ): Promise { - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); // Migrate old shared pin to backend-specific location (one-time migration) migrateVersionPin(backend); @@ -186,19 +211,25 @@ export async function ensureCLIProxyBinary( /** Check if CLIProxyAPI binary is installed */ export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ 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 effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ 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 effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); return readInstalledVersion( getBackendBinDir(effectiveBackend), BACKEND_CONFIG[effectiveBackend].fallbackVersion @@ -224,7 +255,9 @@ export async function installCliproxyVersion( backend?: CLIProxyBackend, deps: InstallCliproxyVersionDeps = {} ): Promise { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const manager = deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); @@ -265,7 +298,9 @@ export async function installCliproxyVersion( /** Fetch the latest CLIProxyAPI version from GitHub API */ export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); return result.latestVersion; } @@ -290,7 +325,9 @@ export interface CliproxyUpdateCheckResult { export async function checkCliproxyUpdate( backend?: CLIProxyBackend ): Promise { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); // Import isNewerVersion for stability check diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 2f534ff2..004ba277 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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'; @@ -211,23 +214,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, { @@ -314,6 +302,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, @@ -361,6 +350,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; @@ -1000,13 +1002,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); } } } diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 3826e581..798511ad 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -18,6 +18,7 @@ import { savePinnedVersion, clearPinnedVersion, isVersionPinned, + resolveLocalBackend, } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector'; import { CLIProxyBackend } from '../types'; @@ -54,8 +55,10 @@ export interface LatestVersionResult { * Get current binary status for a specific backend */ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); const backendConfig = BACKEND_CONFIG[effectiveBackend]; return { installed: isCLIProxyInstalled(effectiveBackend), @@ -71,8 +74,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { * Check for latest version */ export async function checkLatestVersion(backend?: CLIProxyBackend): Promise { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); try { // Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo) @@ -119,8 +124,10 @@ export async function installVersion( }; } - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); try { await installCliproxyVersion(version, verbose, effectiveBackend); @@ -147,8 +154,10 @@ export async function installLatest( verbose = false, backend?: CLIProxyBackend ): Promise { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); try { const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); @@ -184,8 +193,10 @@ export async function installLatest( * Check if a version is pinned */ export function isPinned(backend?: CLIProxyBackend): boolean { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); return isVersionPinned(effectiveBackend); } @@ -193,8 +204,10 @@ export function isPinned(backend?: CLIProxyBackend): boolean { * Get pinned version if any */ export function getPinned(backend?: CLIProxyBackend): string | null { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); return getPinnedVersion(effectiveBackend); } @@ -202,7 +215,9 @@ export function getPinned(backend?: CLIProxyBackend): string | null { * Clear version pin */ export function clearPin(backend?: CLIProxyBackend): void { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); clearPinnedVersion(effectiveBackend); } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index f8970270..f15ae110 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -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; } diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 52c4cb02..7456a5ba 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -6,14 +6,13 @@ */ import { CLIProxyBackend } from '../../cliproxy/types'; -import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { getConfiguredBackend, resolveLocalBackend } 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'; @@ -74,9 +73,10 @@ function parseBackendArg(args: string[]): { * 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; + if (cliBackend) { + return resolveLocalBackend(cliBackend, { warnOnFallback: true }); + } + return getConfiguredBackend({ warnOnFallback: true }); } /** diff --git a/src/commands/cliproxy/install-subcommand.ts b/src/commands/cliproxy/install-subcommand.ts index f5f59036..2e898063 100644 --- a/src/commands/cliproxy/install-subcommand.ts +++ b/src/commands/cliproxy/install-subcommand.ts @@ -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) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a3548fc6..39b3a8aa 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -410,7 +410,7 @@ function mergeWithDefaults(partial: Partial): 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: { diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 472bd168..de23e6ef 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -211,7 +211,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; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index e580d993..7e57dac2 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -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, + getConfiguredBackend, + getInstalledCliproxyVersion, +} 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,16 +145,6 @@ 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, getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion @@ -409,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise = */ router.get('/update-check', async (_req: Request, res: Response): Promise => { try { - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); 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 => { try { - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); 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 => { return; } - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); const installResult = await installDashboardCliproxyVersion(version, backend); res.json({ diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 8ea78bb5..3fc32388 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -1,4 +1,4 @@ -import { installCliproxyVersion } from '../../cliproxy/binary-manager'; +import { installCliproxyVersion, resolveLocalBackend } 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 +52,13 @@ export async function installDashboardCliproxyVersion( backend: CLIProxyBackend, deps: InstallDashboardCliproxyVersionDeps = defaultDeps ): Promise { - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + 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 { diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 4b7d686d..f11cee5c 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -25,6 +25,54 @@ 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('attempts to stop the proxy even when there is no tracked running session', async () => { const calls = { stopProxy: 0, diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 7e98a4ff..25ef9a21 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -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,12 @@ 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('updates provider and regenerates provider-specific core env in same settings file', () => { const result = updateVariant('demo', { provider: 'codex', diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 6522dd58..8c08b01e 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -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({ 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({ 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', }); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index 40578506..60470a3c 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -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,20 +26,21 @@ 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' )); const ccsDir = path.join(tempHome, '.ccs'); - const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus'); - fs.mkdirSync(plusBinDir, { recursive: true }); + const originalBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'original'); + fs.mkdirSync(originalBinDir, { recursive: true }); setGlobalConfigDir(ccsDir); const config = createEmptyUnifiedConfig(); config.cliproxy = { backend: 'plus' }; saveUnifiedConfig(config); - writeInstalledVersion(plusBinDir, '6.6.80'); + writeInstalledVersion(originalBinDir, '6.6.80'); + writeVersionCache('6.6.89', 'original'); writeVersionListCache( { versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], @@ -46,7 +48,7 @@ beforeAll(async () => { latest: '6.6.89', checkedAt: Date.now(), }, - 'plus' + 'original' ); ({ default: cliproxyStatsRoutes } = await import( @@ -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); diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index ce90a1d1..218d4326 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -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() { >
{t('settingsProxy.backendPlusApi')} - - {t('settingsProxy.default')} -

{t('settingsProxy.plusDesc')}

@@ -509,10 +506,22 @@ export default function ProxySection() { >
{t('settingsProxy.backendApi')} + + {t('settingsProxy.default')} +

{t('settingsProxy.originalDesc')}

+ {backend === 'plus' && ( + + + + CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the + original backend until issue #1062 is resolved. + + + )} {/* Warning when original backend selected with Kiro/ghcp variants */} {backend === 'original' && hasKiroGhcpVariants && (