feat(cliproxy): route plus dashboard to maintained fork (#1173)

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-03 13:01:04 -04:00
committed by GitHub
parent 0f36f7c845
commit 923683bf30
11 changed files with 217 additions and 14 deletions
+2 -1
View File
@@ -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).
+1
View File
@@ -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`.
@@ -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 |
@@ -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');
+49 -5
View File
@@ -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
}
+2 -2
View File
@@ -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 {
+12
View File
@@ -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<UnifiedConfig>): 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: {
+3
View File
@@ -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()
+2
View File
@@ -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) */
+18 -4
View File
@@ -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' });
+8 -2
View File
@@ -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<CliproxyBackendConfig>('/cliproxy-server/backend'),
/** Update backend setting */
updateBackend: (backend: 'original' | 'plus', force = false) =>
request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend', {
request<CliproxyBackendConfig>('/cliproxy-server/backend', {
method: 'PUT',
body: JSON.stringify({ backend, force }),
}),