mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
chore: merge origin/dev into pr-1061 fix branch
This commit is contained in:
commit
5ff0370c1c
96 files changed
+3128
-624
No files matched your search
+17
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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('# ----------------------------------------------------------------------------');
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface CursorAnthropicRequest {
|
||||
system?: string | AnthropicTextBlock[];
|
||||
stream?: boolean;
|
||||
tools?: CursorTool[];
|
||||
output_config?: {
|
||||
effort?: string;
|
||||
};
|
||||
thinking?: {
|
||||
type?: string;
|
||||
budget_tokens?: number;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user