chore: merge origin/dev into pr-1061 fix branch

This commit is contained in:
Tam Nhu Tran committed 2026-04-22 22:38:20 -04:00
commit 5ff0370c1c
96 files changed
+3128 -624

No files matched your search

+17 -4
View File
@@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors';
import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions
import { execClaude } from './utils/shell-executor';
import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
@@ -1363,8 +1363,21 @@ async function main(): Promise<void> {
console.error(info(`Global env: ${envNames}`));
}
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
// values from prior sessions cannot leak into the active profile.
// For Claude target launches that already pass `--settings`, keep runtime
// env free of ANTHROPIC routing/auth while preserving non-routing profile
// env so nested Team/subagent sessions can still inherit model intent and
// other profile-scoped runtime flags.
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
};
// Non-Claude targets still need effective credentials injected directly.
const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...settingsEnv,
@@ -1476,7 +1489,7 @@ async function main(): Promise<void> {
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv });
} else if (profileInfo.type === 'account') {
// NEW FLOW: Account-based profile (work, personal)
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
+146
View File
@@ -37,10 +37,26 @@ interface AutoPausedFile {
sessions: AutoPausedSession[];
}
interface QuotaPausedEntry {
provider: CLIProxyProvider;
accountId: string;
pausedAt: string;
until: number;
reason: 'quota_exhausted';
}
interface QuotaPausedFile {
entries: QuotaPausedEntry[];
}
function getAutoPausedPath(): string {
return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json');
}
function getQuotaPausedPath(): string {
return path.join(getCcsDir(), 'cliproxy', 'quota-paused.json');
}
function loadAutoPaused(): AutoPausedFile {
try {
const filePath = getAutoPausedPath();
@@ -69,6 +85,48 @@ function saveAutoPaused(data: AutoPausedFile): void {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
}
function loadQuotaPaused(): QuotaPausedFile {
try {
const filePath = getQuotaPausedPath();
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
entries?: unknown;
};
if (Array.isArray(data.entries)) {
return {
entries: data.entries.filter(
(entry): entry is QuotaPausedEntry =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as QuotaPausedEntry).provider === 'string' &&
typeof (entry as QuotaPausedEntry).accountId === 'string' &&
typeof (entry as QuotaPausedEntry).pausedAt === 'string' &&
Number.isFinite((entry as QuotaPausedEntry).until)
),
};
}
}
} catch {
// Corrupted or malformed file — start fresh
}
return { entries: [] };
}
function saveQuotaPaused(data: QuotaPausedFile): void {
const filePath = getQuotaPausedPath();
if (data.entries.length === 0) {
try {
fs.unlinkSync(filePath);
} catch {
/* already gone */
}
return;
}
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
}
/**
* Check if a process is alive. NOTE: PIDs can be recycled by the OS.
* If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits.
@@ -325,6 +383,93 @@ export function cleanupStaleAutoPauses(): void {
}
}
/**
* Resume quota-paused accounts whose cooldown windows have expired.
* Auto-resume only applies to pauses created by CCS quota handling.
*/
export function restoreExpiredQuotaPauses(now = Date.now()): number {
const data = loadQuotaPaused();
if (data.entries.length === 0) return 0;
const keep: QuotaPausedEntry[] = [];
let resumed = 0;
const registry = loadAccountsRegistry();
for (const entry of data.entries) {
if (!Number.isFinite(entry.until) || entry.until > now) {
keep.push(entry);
continue;
}
const account = registry.providers[entry.provider]?.accounts[entry.accountId];
if (!account?.paused) {
continue;
}
// Only auto-resume the exact pause CCS created for quota cooldown.
// Missing or changed pausedAt metadata is treated as a mismatch so we do
// not accidentally resume a manually paused account.
if (account.pausedAt !== entry.pausedAt) {
continue;
}
if (resumeAccount(entry.provider, entry.accountId)) {
resumed += 1;
continue;
}
// Resume failures are treated as transient I/O/state issues. Keep the
// quota-pause record so the next restore pass can retry instead of leaving
// the account paused forever without any cooldown metadata.
keep.push(entry);
}
saveQuotaPaused({ entries: keep });
return resumed;
}
/**
* Temporarily remove an exhausted account from CLIProxy rotation for the
* configured cooldown window. Returns false when the account was already paused
* or could not be paused, so callers can fall back to in-memory cooldown only.
*/
export function pauseAccountForQuotaCooldown(
provider: CLIProxyProvider,
accountId: string,
cooldownMinutes: number,
now = Date.now()
): boolean {
const registryBefore = loadAccountsRegistry();
const accountBefore = registryBefore.providers[provider]?.accounts[accountId];
if (!accountBefore || accountBefore.paused) {
return false;
}
if (!pauseAccount(provider, accountId)) {
return false;
}
const registryAfter = loadAccountsRegistry();
const pausedAt = registryAfter.providers[provider]?.accounts[accountId]?.pausedAt;
if (!pausedAt) {
return false;
}
const data = loadQuotaPaused();
data.entries = data.entries.filter(
(entry) => !(entry.provider === provider && entry.accountId === accountId)
);
data.entries.push({
provider,
accountId,
pausedAt,
until: now + cooldownMinutes * 60 * 1000,
reason: 'quota_exhausted',
});
saveQuotaPaused(data);
return true;
}
/**
* Enforce provider isolation by auto-pausing conflicting accounts in other providers.
* Records paused accounts for crash recovery and session exit restore.
@@ -553,6 +698,7 @@ export async function handleQuotaExhaustion(
const alternative = await findHealthyAccount(provider, [accountId]);
if (alternative) {
pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes);
setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id);
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
+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
+8
View File
@@ -264,9 +264,17 @@ export function mergeCatalog(
mergedIds.add(remote.id.toLowerCase());
if (staticEntry) {
const mergedThinking = remoteEntry.thinking
? {
...remoteEntry.thinking,
maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel,
}
: staticEntry.thinking;
// Merge: remote overrides, static fills gaps
mergedModels.push({
...remoteEntry,
thinking: mergedThinking,
// Preserve static-only fields
tier: staticEntry.tier,
broken: staticEntry.broken,
-63
View File
@@ -42,7 +42,6 @@ interface ProviderSettings {
const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */
const UPSTREAM_MODEL_PREFIX = 'claude-';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
@@ -62,10 +61,6 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
/**
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
@@ -130,58 +125,6 @@ function migrateDeprecatedModelNames(
return migrated;
}
/**
* Migrate codex effort-suffixed model IDs in settings to canonical IDs.
* Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex
*/
function migrateCodexEffortSuffixes(
settingsPath: string,
provider: CLIProxyProvider,
settings: ProviderSettings
): boolean {
if (provider !== 'codex') return false;
if (!settings.env || typeof settings.env !== 'object') return false;
let migrated = false;
for (const key of MODEL_ENV_VAR_KEYS) {
const value = settings.env[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
settings.env[key] = canonical;
migrated = true;
}
}
if (Array.isArray(settings.presets)) {
for (const preset of settings.presets) {
if (!preset || typeof preset !== 'object') continue;
const presetRecord = preset as Record<string, unknown>;
for (const key of PRESET_MODEL_KEYS) {
const value = presetRecord[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
presetRecord[key] = canonical;
migrated = true;
}
}
}
}
if (migrated) {
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
} catch {
// Best-effort migration — don't block startup if write fails
}
}
return migrated;
}
/**
* Migrate legacy iFlow model IDs to current upstream model IDs.
* Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2
@@ -510,8 +453,6 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(expandedPath, provider, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(expandedPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
// Custom variant settings found - merge with global env
@@ -545,8 +486,6 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(settingsPath, provider, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(settingsPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
// User override found - merge with global env
@@ -719,7 +658,6 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(expandedPath, provider, settings);
migrateCodexEffortSuffixes(expandedPath, provider, settings);
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
@@ -739,7 +677,6 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(settingsPath, provider, settings);
migrateCodexEffortSuffixes(settingsPath, provider, settings);
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
+12 -2
View File
@@ -7,7 +7,7 @@ import type { CLIProxyProvider } from '../types';
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
import type { ThinkingConfig } from '../../config/unified-config-types';
import { getThinkingConfig } from '../../config/unified-config-loader';
import { supportsThinking } from '../model-catalog';
import { getModelThinkingSupport, supportsThinking } from '../model-catalog';
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
import { normalizeModelIdForProvider } from '../model-id-normalizer';
import { warn } from '../../utils/ui';
@@ -86,7 +86,8 @@ function applyThinkingSuffixForProvider(
const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/);
// Existing parenthesized suffix:
// - keep as-is for non-codex providers
// - keep as-is for non-codex providers unless the target model now expects
// named levels and we need to rewrite an old numeric suffix
// - for codex effort levels, normalize to codex model suffix style
if (parenthesizedSuffixMatch) {
if (provider === 'codex') {
@@ -95,6 +96,15 @@ function applyThinkingSuffixForProvider(
return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`);
}
}
if (provider) {
const normalizedBaseModel = normalizeModelForThinkingLookup(model, provider);
const thinking = getModelThinkingSupport(provider, normalizedBaseModel);
if (thinking?.type === 'levels') {
return model.replace(/\([^)]+\)$/, `(${thinkingValue})`);
}
}
return model;
}
+22 -20
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';
@@ -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;
@@ -572,7 +574,7 @@ export async function execClaudeWithCLIProxy(
console.error(' Alias: --thinking xhigh (same behavior)');
} else {
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, auto');
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
}
process.exit(1);
@@ -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);
}
}
}
+8 -5
View File
@@ -33,7 +33,7 @@ export interface ThinkingSupport {
/** Valid level names (for levels type) */
levels?: string[];
/** Maximum reasoning effort level (caps effort at this level for levels type) */
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
/** Whether zero/disabled thinking is allowed */
zeroAllowed?: boolean;
/** Whether dynamic/auto thinking is allowed */
@@ -295,11 +295,14 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
name: 'Claude Opus 4.7',
description: 'Latest flagship model',
nativeImageInput: true,
// Opus 4.7 only supports adaptive thinking on the Anthropic API; manual
// thinking.type: "enabled" with budget_tokens is rejected with 400.
// Expose effort levels; the proxy translates these into adaptive effort.
// `max` is a distinct adaptive effort above `xhigh` exposed by Anthropic.
thinking: {
type: 'budget',
min: 1024,
max: 128000,
zeroAllowed: false,
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
maxLevel: 'max',
dynamicAllowed: true,
},
extendedContext: true,
+1 -9
View File
@@ -15,16 +15,8 @@ import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
import { normalizeModelIdForProvider } from './model-id-normalizer';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): string {
if (provider !== 'codex') return model;
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string {
const withoutCodexSuffix = stripCodexEffortSuffix(model, provider);
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
return normalizeModelIdForProvider(model, provider);
}
/**
+22 -9
View File
@@ -65,6 +65,18 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st
};
}
function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } {
const match = model.match(CODEX_EFFORT_SUFFIX_REGEX);
if (!match?.[0]) {
return { baseModel: model, suffix: '' };
}
return {
baseModel: model.slice(0, -match[0].length),
suffix: match[0],
};
}
/**
* Extract provider segment from `/api/provider/{provider}` request paths.
*
@@ -97,20 +109,24 @@ export function isIFlowProvider(provider: ProviderLike): boolean {
return provider.trim().toLowerCase() === 'iflow';
}
/** Normalize Codex effort-suffixed IDs to canonical IDs. */
/** Strip Codex effort suffixes while preserving trailing config suffixes. */
export function stripCodexEffortSuffix(model: string): string {
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
const trimmed = trimModelId(model);
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel);
return `${withoutEffort}${suffix}`;
}
/** Normalize legacy Codex aliases to the current public Codex model IDs. */
export function normalizeCodexLegacyModelAliases(model: string): string {
const trimmed = trimModelId(model);
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()];
const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel);
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()];
if (!replacement) {
return trimmed;
}
return `${replacement}${suffix}`;
return `${replacement}${effortSuffix}${suffix}`;
}
/**
@@ -217,15 +233,12 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
/**
* Canonicalize model ID for provider-specific compatibility.
* - Codex: strip effort suffixes.
* - Codex: preserve valid effort suffixes while normalizing legacy aliases.
* - Antigravity: normalize dotted/historical aliases.
*/
export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string {
const trimmedModel = trimModelId(model);
const withoutCodexSuffix = isCodexProvider(provider)
? stripCodexEffortSuffix(trimmedModel)
: trimmedModel;
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
return normalizeModelIdForProvider(trimmedModel, provider);
}
/**
+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)
+33 -3
View File
@@ -286,6 +286,9 @@ export async function findHealthyAccount(
return null;
}
const { restoreExpiredQuotaPauses } = await import('./account-safety');
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig();
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free'];
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
@@ -388,6 +391,11 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return { proceed: true, accountId: defaultAccount?.id || '' };
}
const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import(
'./account-safety'
);
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig();
const quotaConfig = config.quota_management;
@@ -442,13 +450,32 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) {
// Apply cooldown to exhausted account
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
return await findAndSwitch(
const alternative = await findHealthyAccount(provider, [defaultAccount.id]);
if (!alternative) {
return {
proceed: true,
accountId: defaultAccount.id,
reason: `Quota exhausted (${avgQuota.toFixed(1)}%), no alternatives available`,
quotaPercent,
};
}
pauseAccountForQuotaCooldown(
provider,
defaultAccount.id,
`Quota exhausted (${avgQuota.toFixed(1)}%)`
quotaConfig.auto?.cooldown_minutes ?? 5
);
setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id);
return {
proceed: true,
accountId: alternative.id,
switchedFrom: defaultAccount.id,
reason: `Quota exhausted (${avgQuota.toFixed(1)}%)`,
quotaPercent: alternative.lastQuota,
};
}
return {
@@ -471,6 +498,9 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
isDefault: boolean;
}>;
}> {
const { restoreExpiredQuotaPauses } = await import('./account-safety');
restoreExpiredQuotaPauses();
const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider);
+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;
+2 -6
View File
@@ -41,16 +41,12 @@ interface SettingsFile {
[key: string]: unknown;
}
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
function canonicalizeModelForProvider(
provider: CLIProxyProfileName | undefined,
model: string
): string {
const withoutCodexSuffix =
provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model;
if (!provider) return withoutCodexSuffix;
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
if (!provider) return model;
return normalizeModelIdForProvider(model, provider);
}
/**
+22 -2
View File
@@ -32,6 +32,11 @@ export interface ThinkingValidationResult {
/**
* Named thinking level mappings to budget values (when converting level→budget)
*
* `max` sits above `xhigh` to represent unconstrained thinking (Claude Opus 4.7,
* Mythos). The numeric value is a CCS-internal mapping, not an Anthropic wire
* value: Opus 4.7 uses adaptive thinking with an effort string, and other
* max-capable models already treat the level as a qualitative cap.
*/
export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
minimal: 512,
@@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
medium: 8192,
high: 24576,
xhigh: 32768,
max: 65536,
};
/**
@@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record<string, number> = {
medium: 3,
high: 4,
xhigh: 5,
max: 6,
};
/**
* Valid thinking level names
*/
export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const;
export const VALID_THINKING_LEVELS = [
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'auto',
] as const;
export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
/**
@@ -121,7 +136,12 @@ function findClosestLevel(input: string, validLevels: string[]): string | undefi
}
}
// Common aliases
// Common aliases.
//
// `max: 'xhigh'` is a graceful fallback for models whose levels list does not
// include `max` (e.g. Codex `gpt-5.4`). Exact match above takes priority, so
// Opus 4.7 (which has `max` in its validLevels) returns `max` directly while
// Codex still maps `max` → `xhigh`.
const aliases: Record<string, string> = {
min: 'minimal',
lo: 'low',
+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
@@ -60,7 +60,7 @@ function showHelp(): void {
console.log(subheader('Levels:'));
console.log(
` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto, off')}`
` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto, off')}`
);
console.log('');
+4 -2
View File
@@ -431,7 +431,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: {
@@ -925,7 +925,9 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push(
'# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)'
);
lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto');
lines.push(
'# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto'
);
lines.push('# Override: Set global override value (number or level name)');
lines.push('# Provider overrides: Per-provider tier defaults');
lines.push('# ----------------------------------------------------------------------------');
+2 -2
View File
@@ -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;
@@ -1023,7 +1023,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
accounts: {},
profiles: {},
cliproxy: {
backend: 'plus',
backend: 'original',
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
+18 -5
View File
@@ -68,17 +68,30 @@ function toToolResultContent(content: unknown, label: string): string {
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
}
function mapThinkingToReasoningEffort(
thinking: CursorAnthropicRequest['thinking']
): string | undefined {
function mapAdaptiveEffortToCursorReasoningEffort(effort: string | undefined): string {
const normalized = effort?.trim().toLowerCase();
if (!normalized || normalized === 'auto') {
return 'high';
}
if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium') {
return 'medium';
}
return 'high';
}
function mapThinkingToReasoningEffort(request: CursorAnthropicRequest): string | undefined {
const thinking = request.thinking;
if (!thinking) {
return undefined;
}
if (thinking.type === 'disabled') {
return undefined;
}
if (thinking.type === 'adaptive') {
return mapAdaptiveEffortToCursorReasoningEffort(request.output_config?.effort);
}
if (thinking.type !== 'enabled') {
throw new Error('thinking.type must be "enabled" or "disabled"');
throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"');
}
return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
? 'high'
@@ -209,7 +222,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ
? request.model
: undefined,
stream: request.stream === true,
reasoning_effort: mapThinkingToReasoningEffort(request.thinking),
reasoning_effort: mapThinkingToReasoningEffort(request),
tools: Array.isArray(request.tools) ? request.tools : undefined,
messages: translatedMessages,
};
+3
View File
@@ -41,6 +41,9 @@ export interface CursorAnthropicRequest {
system?: string | AnthropicTextBlock[];
stream?: boolean;
tools?: CursorTool[];
output_config?: {
effort?: string;
};
thinking?: {
type?: string;
budget_tokens?: number;
+51 -2
View File
@@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
import {
getClaudeLaunchEnvOverrides,
stripAnthropicRoutingEnv,
stripClaudeCodeEnv,
} from '../utils/shell-executor';
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
import {
appendThirdPartyImageAnalysisToolArgs,
@@ -38,6 +43,11 @@ import {
import { resolveCliproxyBridgeMetadata } from '../api/services';
import { ensureCliproxyService } from '../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import {
buildOpenAICompatProxyEnv,
resolveOpenAICompatProfileConfig,
startOpenAICompatProxy,
} from '../proxy';
import {
appendThirdPartyWebSearchToolArgs,
appendWebSearchTrace,
@@ -129,6 +139,14 @@ export class HeadlessExecutor {
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
const settings = loadSettings(settingsPath);
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const settingsEnv = settings.env || {};
const openAICompatProfile = resolveOpenAICompatProfileConfig(
profile,
settingsPath,
settingsEnv
);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
let imageAnalysisFallbackHookReady: boolean | undefined;
if (imageAnalysisMcpReady) {
@@ -207,6 +225,33 @@ export class HeadlessExecutor {
}
}
let runtimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
};
if (openAICompatProfile) {
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
insecure: openAICompatProfile.insecure,
});
if (!proxyStart.success) {
throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy');
}
runtimeEnvVars = {
...runtimeEnvVars,
...buildOpenAICompatProxyEnv(
openAICompatProfile,
proxyStart.port,
proxyStart.authToken || '',
inheritedClaudeConfigDir
),
};
delete runtimeEnvVars.ANTHROPIC_API_KEY;
}
// Smart slash command detection and preservation
const processedPrompt = this._processSlashCommand(enhancedPrompt);
@@ -321,6 +366,7 @@ export class HeadlessExecutor {
sessionMgr,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
runtimeEnvVars,
traceEnv,
});
}
@@ -340,6 +386,7 @@ export class HeadlessExecutor {
sessionMgr: SessionManager;
claudeConfigDir?: string;
imageAnalysisEnv?: Record<string, string>;
runtimeEnvVars?: NodeJS.ProcessEnv;
traceEnv?: Record<string, string>;
}
): Promise<ExecutionResult> {
@@ -352,6 +399,7 @@ export class HeadlessExecutor {
sessionMgr,
claudeConfigDir,
imageAnalysisEnv = {},
runtimeEnvVars = {},
traceEnv = {},
} = ctx;
@@ -368,9 +416,10 @@ export class HeadlessExecutor {
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const cleanEnv = stripClaudeCodeEnv({
...process.env,
...stripAnthropicRoutingEnv(process.env),
...getClaudeLaunchEnvOverrides(),
...getWebSearchHookEnv(),
...runtimeEnvVars,
...imageAnalysisEnv,
...traceEnv,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
@@ -396,7 +396,7 @@ function mapThinkingToReasoning(
};
}
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
function resolveOutputConfigEffort(
outputConfig: AnthropicOutputConfig | undefined
@@ -416,7 +416,7 @@ function resolveOutputConfigEffort(
* for Codex; for generic OpenAI-compat providers we clamp to high.
*/
function toOpenAIEffort(effort: string): string {
return effort === 'max' ? 'high' : effort;
return effort === 'max' || effort === 'xhigh' ? 'high' : effort;
}
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
+44
View File
@@ -9,6 +9,9 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as lockfile from 'proper-lockfile';
import { getModelThinkingSupport } from '../cliproxy/model-catalog';
import { validateThinking } from '../cliproxy/thinking-validator';
import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils';
const CCS_MODEL_PREFIX = 'ccs-';
const CCS_DISPLAY_PREFIX = 'CCS ';
@@ -144,6 +147,24 @@ function toAnthropicBudget(value: string | number): number {
return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high;
}
function resolveAnthropicModelId(model: string): string {
return stripModelConfigurationSuffixes(model);
}
function usesAnthropicAdaptiveThinking(model: string): boolean {
return getModelThinkingSupport('claude', resolveAnthropicModelId(model))?.type === 'levels';
}
function toAnthropicAdaptiveEffort(model: string, value: string | number): string | undefined {
const validation = validateThinking('claude', resolveAnthropicModelId(model), value);
if (isReasoningOffValue(validation.value)) {
return undefined;
}
const normalized = String(validation.value).trim().toLowerCase();
return normalized === 'auto' ? undefined : normalized;
}
function toReasoningEffort(value: string | number): string {
if (typeof value === 'number') {
if (value <= 4000) return 'low';
@@ -181,12 +202,35 @@ function applyReasoningOverride(
if (isReasoningOffValue(reasoningOverride)) {
delete extraArgs.thinking;
delete extraArgs.output_config;
} else if (usesAnthropicAdaptiveThinking(entry.model)) {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
const outputConfig = isObject(extraArgs.output_config) ? { ...extraArgs.output_config } : {};
const effort = toAnthropicAdaptiveEffort(entry.model, reasoningOverride);
thinking.type = 'adaptive';
delete thinking.budget_tokens;
delete thinking.budgetTokens;
if (effort) {
outputConfig.effort = effort;
} else {
delete outputConfig.effort;
}
extraArgs.thinking = thinking;
if (Object.keys(outputConfig).length > 0) {
extraArgs.output_config = outputConfig;
} else {
delete extraArgs.output_config;
}
} else {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
thinking.type = 'enabled';
thinking.budget_tokens = toAnthropicBudget(reasoningOverride);
delete thinking.budgetTokens;
extraArgs.thinking = thinking;
delete extraArgs.output_config;
}
} else if (provider === 'openai') {
delete extraArgs.reasoning_effort;
+83 -22
View File
@@ -26,6 +26,71 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return result;
}
const ANTHROPIC_ROUTING_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
];
const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS);
const ANTHROPIC_MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_SMALL_FAST_MODEL',
];
const TMUX_SYNC_ENV_KEYS = [
'CLAUDE_CONFIG_DIR',
'CCS_PROFILE_TYPE',
'CCS_WEBSEARCH_SKIP',
'CCS_STRIP_INHERITED_ANTHROPIC_ENV',
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
...ANTHROPIC_MODEL_ENV_KEYS,
...ANTHROPIC_ROUTING_ENV_KEYS,
];
/**
* Strip inherited Anthropic routing/auth env while preserving model intent.
* Used for nested settings-profile Claude launches where `--settings` already
* defines the provider transport and the parent process should only lend model
* defaults or effort hints.
*/
export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const key of Object.keys(env)) {
if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) {
result[key] = env[key];
}
}
return result;
}
function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string | undefined): void {
if (!process.env.TMUX) {
return;
}
const nestedSessionEnv =
profileType === 'account' || profileType === 'default'
? stripAnthropicEnv(env)
: profileType === 'settings'
? stripAnthropicRoutingEnv(env)
: env;
for (const key of TMUX_SYNC_ENV_KEYS) {
try {
const value = nestedSessionEnv[key];
if (value !== undefined) {
spawnSync('tmux', ['setenv', key, value], { stdio: 'ignore' });
} else {
spawnSync('tmux', ['setenv', '-u', key], { stdio: 'ignore' });
}
} catch {
// tmux setenv can fail if not in a tmux session; safe to ignore
}
}
}
/**
* Strip inherited browser attach/runtime env vars from a process environment.
*
@@ -153,14 +218,18 @@ export function execClaude(
const webSearchEnv = getWebSearchHookEnv();
const claudeLaunchEnv = getClaudeLaunchEnvOverrides();
// For account/default profiles, strip ANTHROPIC_* from parent env to prevent
// stale proxy config (e.g., from prior CLIProxy sessions) from interfering
// with native Claude API routing. Settings-based profiles explicitly inject
// their own ANTHROPIC_* values, so they don't need this protection.
// Strip inherited ANTHROPIC_* when the launch should not reuse parent routing.
// Account/default profiles need full isolation from prior proxy sessions.
// Settings profiles can selectively strip only routing/auth when `--settings`
// already carries the provider source of truth but the parent model intent
// should still flow into nested Team/subagent launches.
const profileType = envVars?.CCS_PROFILE_TYPE;
const inheritedEnv =
profileType === 'account' || profileType === 'default'
? stripAnthropicEnv(process.env)
const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default';
const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1';
const inheritedEnv = stripInheritedAnthropicEnv
? stripAnthropicEnv(process.env)
: stripInheritedAnthropicRoutingEnv
? stripAnthropicRoutingEnv(process.env)
: process.env;
const baseEnv = stripBrowserEnv(inheritedEnv);
@@ -168,10 +237,13 @@ export function execClaude(
const mergedEnv = envVars
? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv }
: { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv };
const effectiveMergedEnv = stripInheritedAnthropicRoutingEnv
? stripAnthropicRoutingEnv(mergedEnv)
: mergedEnv;
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(mergedEnv);
const env = stripClaudeCodeEnv(effectiveMergedEnv);
if (profileType !== 'account') {
try {
@@ -181,20 +253,9 @@ export function execClaude(
}
}
// propagate key env vars to tmux session so agent team teammates
// (spawned via tmux split-window) inherit the correct config dir
if (process.env.TMUX && envVars) {
const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP'];
for (const key of tmuxPropagateVars) {
if (envVars[key]) {
try {
spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' });
} catch {
// tmux setenv can fail if not in a tmux session; safe to ignore
}
}
}
}
// Keep tmux teammate panes aligned with the nested-safe Claude runtime env
// rather than the tmux server's original shell environment.
syncTmuxNestedSessionEnv(env, profileType);
let child: ChildProcess;
if (isPowerShellScript) {
+6 -2
View File
@@ -3,7 +3,7 @@
* Session-based auth with httpOnly cookies for CCS dashboard.
*/
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type { NextFunction, Request, Response } from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
@@ -84,7 +84,11 @@ export const loginRateLimiter = rateLimit({
/**
* Create session middleware configured for CCS dashboard.
*/
export function createSessionMiddleware(): RequestHandler {
export function createSessionMiddleware(): (
req: Request,
res: Response,
next: NextFunction
) => void {
const authConfig = getDashboardAuthConfig();
const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000;
+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 {