diff --git a/README.md b/README.md index 5a1480a0..93577d2a 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ config. Deep dive: Manage OAuth-backed providers, quota visibility, and proxy-wide routing from one place. CCS now surfaces round-robin vs fill-first natively in both CLI and dashboard flows instead of hiding that choice inside raw upstream controls. The original CLIProxyAPI backend remains the default; the -community-maintained CLIProxyAPIPlus fork is opt-in for plus-only providers. +community-maintained CLIProxyAPIPlus fork is opt-in for plus-only providers. When Plus is selected, +CCS points the embedded management panel at the maintained CPAMC dashboard fork by default. Deep dive: [CLIProxy API](https://docs.ccs.kaitran.ca/features/proxy/cliproxy-api). diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 5215f461..2521db5c 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes. - **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases. - **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account. - **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`. diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index e408c2c1..2eb12953 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -16,6 +16,8 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, GitHub Copilot, Cursor, GitLab, CodeBuddy, and Kilo. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers. +Generated local CLIProxy configs also keep the management dashboard aligned with the selected backend. `backend: original` uses upstream CPAMC (`router-for-me/Cli-Proxy-API-Management-Center`), while `backend: plus` uses the CCS-maintained dashboard fork (`kaitranntt/Cli-Proxy-API-Management-Center`). Advanced users can override the generated `remote-management.panel-github-repository` value by setting `cliproxy.management_panel_repository` in `~/.ccs/config.yaml`; CCS will regenerate stale local CLIProxy configs when the expected dashboard repository changes. + ``` +===========================================================================+ | CLIProxyAPI Integration | diff --git a/src/cliproxy/config/__tests__/config-generator.test.js b/src/cliproxy/config/__tests__/config-generator.test.js index 9441d7ad..541f1d61 100644 --- a/src/cliproxy/config/__tests__/config-generator.test.js +++ b/src/cliproxy/config/__tests__/config-generator.test.js @@ -585,6 +585,124 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" }); }); + describe('management panel repository selection', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + const ORIGINAL_PANEL_REPO = 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'; + const PLUS_PANEL_REPO = 'https://github.com/kaitranntt/Cli-Proxy-API-Management-Center'; + + let testDir; + let originalCcsHome; + let regenerateConfig; + let configNeedsRegeneration; + + function loadGenerator() { + delete require.cache[require.resolve('../../../../dist/cliproxy/config/config-generator')]; + delete require.cache[require.resolve('../../../../dist/utils/config-manager')]; + delete require.cache[require.resolve('../../../../dist/config/config-loader-facade')]; + delete require.cache[require.resolve('../../../../dist/config/unified-config-loader')]; + return require('../../../../dist/cliproxy/config/config-generator'); + } + + function writeUnifiedConfig(cliproxyYaml) { + const ccsDir = path.join(testDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + `version: 999\ncliproxy:\n${cliproxyYaml}` + ); + } + + function readGeneratedConfig() { + const configPath = path.join(testDir, '.ccs', 'cliproxy', 'config.yaml'); + return fs.readFileSync(configPath, 'utf-8'); + } + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-panel-repo-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; + + const configGenerator = loadGenerator(); + regenerateConfig = configGenerator.regenerateConfig; + configNeedsRegeneration = configGenerator.configNeedsRegeneration; + }); + + afterEach(() => { + process.env.CCS_HOME = originalCcsHome; + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('uses the upstream management dashboard for the original backend', () => { + writeUnifiedConfig(' backend: original\n'); + + regenerateConfig(); + + const config = readGeneratedConfig(); + assert( + config.includes(`panel-github-repository: "${ORIGINAL_PANEL_REPO}"`), + 'Original backend should use upstream CPAMC repository' + ); + }); + + it('uses the maintained management dashboard fork for the Plus backend', () => { + writeUnifiedConfig(' backend: plus\n'); + + regenerateConfig(); + + const config = readGeneratedConfig(); + assert( + config.includes(`panel-github-repository: "${PLUS_PANEL_REPO}"`), + 'Plus backend should use the CCS-maintained CPAMC fork' + ); + }); + + it('lets users override the management dashboard repository', () => { + const customRepo = 'https://github.com/example/custom-panel'; + writeUnifiedConfig(` backend: plus\n management_panel_repository: "${customRepo}"\n`); + + regenerateConfig(); + + const config = readGeneratedConfig(); + assert( + config.includes(`panel-github-repository: "${customRepo}"`), + 'Explicit dashboard repository override should win over backend defaults' + ); + }); + + it('marks v18 generated configs stale so the panel repository is backfilled', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync( + path.join(cliproxyDir, 'config.yaml'), + '# CLIProxyAPI config generated by CCS v18\nport: 8317\n' + ); + + assert.strictEqual(configNeedsRegeneration(), true); + }); + + it('checks the generated config for the requested local port', () => { + writeUnifiedConfig(' backend: plus\n'); + + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync( + path.join(cliproxyDir, 'config-8318.yaml'), + `# CLIProxyAPI config generated by CCS v19 +port: 8318 +remote-management: + panel-github-repository: "${ORIGINAL_PANEL_REPO}" +` + ); + + assert.strictEqual(configNeedsRegeneration(8318), true); + }); + }); + describe('oauth-model-alias fork:true', () => { const fs = require('fs'); const os = require('os'); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index a34d8a45..95e50bd0 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { CLIProxyProvider, ProviderConfig } from '../types'; +import type { CLIProxyBackend, CLIProxyProvider, ProviderConfig } from '../types'; import { getProviderDisplayName } from '../provider-capabilities'; import { getModelMappingFromConfig } from '../config/base-config-loader'; import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types'; @@ -42,8 +42,14 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges * v17: Persist routing.strategy from CCS unified config * v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config + * v19: Persist backend-aware management panel repository from CCS unified config */ -export const CLIPROXY_CONFIG_VERSION = 18; +export const CLIPROXY_CONFIG_VERSION = 19; + +export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY = + 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'; +export const PLUS_MANAGEMENT_PANEL_REPOSITORY = + 'https://github.com/kaitranntt/Cli-Proxy-API-Management-Center'; interface RegenerateConfigOptions { configPath?: string; @@ -145,6 +151,33 @@ function getSessionAffinityTtl(): string { return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h'; } +function normalizeManagementPanelRepository(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function getDefaultManagementPanelRepository(backend: CLIProxyBackend | undefined): string { + return backend === 'plus' + ? PLUS_MANAGEMENT_PANEL_REPOSITORY + : ORIGINAL_MANAGEMENT_PANEL_REPOSITORY; +} + +export function getManagementPanelRepository(): string { + const config = loadOrCreateUnifiedConfig(); + return ( + normalizeManagementPanelRepository(config.cliproxy?.management_panel_repository) ?? + getDefaultManagementPanelRepository(config.cliproxy?.backend) + ); +} + +function quoteYamlString(value: string): string { + return JSON.stringify(value); +} + function hasPositiveDuration(value: string): boolean { const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); if (!segments) { @@ -168,6 +201,11 @@ function sanitizeYamlScalar(rawValue: string): string { return trimmed; } +function parseManagementPanelRepository(content: string): string | null { + const match = content.match(/^\s*panel-github-repository:\s*(.+?)\s*$/m); + return match ? sanitizeYamlScalar(match[1]) : null; +} + function normalizeAntigravityAlias(rawAlias: string): string { const normalized = sanitizeYamlScalar(rawAlias); if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) { @@ -579,6 +617,7 @@ function generateUnifiedConfigContent( const routingStrategy = getRoutingStrategy(); const sessionAffinityEnabled = getSessionAffinityEnabled(); const sessionAffinityTtl = getSessionAffinityTtl(); + const managementPanelRepository = getManagementPanelRepository(); // Get effective auth tokens (respects user customization) const effectiveApiKey = getEffectiveApiKey(); @@ -633,6 +672,7 @@ remote-management: allow-remote: true secret-key: "${effectiveSecret}" disable-control-panel: false + panel-github-repository: ${quoteYamlString(managementPanelRepository)} # ============================================================================= # Reliability & Quota Management @@ -858,8 +898,8 @@ export function regenerateConfig( * Check if config needs regeneration (version mismatch) * @returns true if config should be regenerated */ -export function configNeedsRegeneration(): boolean { - const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT); +export function configNeedsRegeneration(port: number = CLIPROXY_DEFAULT_PORT): boolean { + const configPath = getConfigPathForPort(port); if (!fs.existsSync(configPath)) { return false; // Will be created on first use } @@ -872,7 +912,11 @@ export function configNeedsRegeneration(): boolean { if (configVersion === null) { return true; // No version marker = old config } - return configVersion < CLIPROXY_CONFIG_VERSION; + if (configVersion < CLIPROXY_CONFIG_VERSION) { + return true; + } + + return parseManagementPanelRepository(content) !== getManagementPanelRepository(); } catch { return true; // Error reading = regenerate } diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 74dd2f39..a6e5ece5 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -178,7 +178,7 @@ export async function ensureCliproxyService( // Check if config needs update (even if running) let configRegenerated = false; - if (configNeedsRegenerationFn()) { + if (configNeedsRegenerationFn(port)) { log('Config outdated, regenerating...'); regenerateConfig(port); configRegenerated = true; @@ -251,7 +251,7 @@ export async function ensureCliproxyService( // 2. Ensure/regenerate config if needed let configPath: string; - if (configNeedsRegeneration()) { + if (configNeedsRegeneration(port)) { log('Config needs regeneration, updating...'); configPath = regenerateConfig(port); } else { diff --git a/src/config/loader/defaults-merger.ts b/src/config/loader/defaults-merger.ts index 98dfa461..ae7487e9 100644 --- a/src/config/loader/defaults-merger.ts +++ b/src/config/loader/defaults-merger.ts @@ -38,6 +38,15 @@ import { normalizeSearxngBaseUrl } from '../../utils/websearch/types'; // mergeWithDefaults // --------------------------------------------------------------------------- +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. @@ -77,6 +86,9 @@ export function mergeWithDefaults(partial: Partial): UnifiedConfi partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' ? partial.cliproxy.backend : undefined, // Invalid values become undefined (defaults to 'original' at runtime) + management_panel_repository: normalizeOptionalString( + partial.cliproxy?.management_panel_repository + ), // Auto-sync - default to true auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true, routing: { diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index 7993c8cb..e9106c4c 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -66,6 +66,9 @@ export function generateYamlWithComments(config: UnifiedConfig): string { lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); lines.push('# Each variant can reference a *.settings.json file for custom env vars.'); lines.push('# Edit the settings file directly to customize model or other settings.'); + lines.push( + '# Optional: cliproxy.management_panel_repository overrides the generated CPAMC repo.' + ); lines.push('# ----------------------------------------------------------------------------'); lines.push( yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() diff --git a/src/config/schemas/cliproxy.ts b/src/config/schemas/cliproxy.ts index 1765335f..0ac4695a 100644 --- a/src/config/schemas/cliproxy.ts +++ b/src/config/schemas/cliproxy.ts @@ -128,6 +128,8 @@ export interface CLIProxyRoutingConfig { export interface CLIProxyConfig { /** Backend selection: 'original' or 'plus' (default: 'original') */ backend?: 'original' | 'plus'; + /** Optional CPAMC dashboard GitHub repository override for generated CLIProxy config */ + management_panel_repository?: string; /** Nickname to email mapping for OAuth accounts */ oauth_accounts: OAuthAccounts; /** Built-in providers (read-only, for reference) */ diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index e25b440b..9b13b1a2 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -17,6 +17,11 @@ import { CliproxyServerConfig, } from '../../config/unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; +import { + configNeedsRegeneration, + getManagementPanelRepository, + regenerateConfig, +} from '../../cliproxy/config/generator'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; @@ -84,12 +89,15 @@ router.put('/', (req: Request, res: Response) => { /** * GET /api/cliproxy-server/backend - Get CLIProxy backend setting - * @returns {{ backend: 'original' | 'plus' }} Current backend configuration + * @returns {{ backend: 'original' | 'plus', managementPanelRepository: string }} Current backend configuration */ router.get('/backend', async (_req: Request, res: Response) => { try { const config = await loadOrCreateUnifiedConfig(); - res.json({ backend: config.cliproxy?.backend ?? DEFAULT_BACKEND }); + res.json({ + backend: config.cliproxy?.backend ?? DEFAULT_BACKEND, + managementPanelRepository: getManagementPanelRepository(), + }); } catch (error) { console.error('[cliproxy-server-routes] Failed to load backend config:', error); res.status(500).json({ error: 'Failed to load backend config' }); @@ -101,7 +109,7 @@ router.get('/backend', async (_req: Request, res: Response) => { * @param {Object} req.body - Request body * @param {'original' | 'plus'} req.body.backend - Backend to switch to * @param {boolean} [req.body.force=false] - Force change even if proxy is running - * @returns {{ backend: 'original' | 'plus' }} Updated backend configuration + * @returns {{ backend: 'original' | 'plus', managementPanelRepository: string }} Updated backend configuration * @throws {400} Invalid backend value * @throws {409} Proxy is running (unless force=true) */ @@ -116,6 +124,8 @@ router.put('/backend', (req: Request, res: Response) => { // Pre-flight read: check running state before acquiring write lock const currentConfig = loadOrCreateUnifiedConfig(); const currentBackend = currentConfig.cliproxy?.backend ?? DEFAULT_BACKEND; + const localPort = + currentConfig.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port; if (currentBackend !== backend && isProxyRunning() && !force) { res.status(409).json({ error: 'Proxy is running. Stop proxy first or use force=true to change backend.', @@ -139,7 +149,11 @@ router.put('/backend', (req: Request, res: Response) => { } }); - res.json({ backend }); + if (configNeedsRegeneration(localPort)) { + regenerateConfig(localPort); + } + + res.json({ backend, managementPanelRepository: getManagementPanelRepository() }); } catch (error) { console.error('[cliproxy-server-routes] Failed to save backend config:', error); res.status(500).json({ error: 'Failed to save backend config' }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index c35034cb..3dca15a6 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -1123,6 +1123,12 @@ export interface CliproxyUpdateCheckResult { stabilityMessage?: string; // Warning message if running unstable version } +/** Backend and management panel repository selected for local CLIProxy */ +export interface CliproxyBackendConfig { + backend: 'original' | 'plus'; + managementPanelRepository: string; +} + /** Available versions list from GitHub releases */ export interface CliproxyVersionsResponse { versions: string[]; @@ -1495,10 +1501,10 @@ export const api = { body: JSON.stringify(config), }), /** Get backend setting */ - getBackend: () => request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend'), + getBackend: () => request('/cliproxy-server/backend'), /** Update backend setting */ updateBackend: (backend: 'original' | 'plus', force = false) => - request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend', { + request('/cliproxy-server/backend', { method: 'PUT', body: JSON.stringify({ backend, force }), }),