mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
feat(cliproxy): route plus dashboard to maintained fork (#1173)
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user