Merge pull request #925 from kaitranntt/kai/feat/cliproxy-routing-guidance

feat(cliproxy): add routing guidance and strategy controls
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-07 19:59:44 -04:00
commit a6beb7d531
33 files changed
+1237 -12

No files matched your search

+12 -1
View File
@@ -38,8 +38,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v14: Added Gemini 3.1 Flash Antigravity aliases for upcoming rollout compatibility
* v15: Prune stale generated Antigravity Gemini preview aliases during regeneration
* v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges
* v17: Persist routing.strategy from CCS unified config
*/
export const CLIPROXY_CONFIG_VERSION = 16;
export const CLIPROXY_CONFIG_VERSION = 17;
interface OAuthModelAliasEntry {
name: string;
@@ -115,6 +116,11 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } {
};
}
function getRoutingStrategy(): 'round-robin' | 'fill-first' {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
}
function sanitizeYamlScalar(rawValue: string): string {
const trimmed = rawValue.trim();
if (
@@ -534,6 +540,7 @@ function generateUnifiedConfigContent(
// Get logging settings from user config (disabled by default)
const { loggingToFile, requestLog } = getLoggingSettings();
const routingStrategy = getRoutingStrategy();
// Get effective auth tokens (respects user customization)
const effectiveApiKey = getEffectiveApiKey();
@@ -606,6 +613,10 @@ quota-exceeded:
switch-project: true
switch-preview-model: true
# Credential selection strategy when multiple matching accounts are available
routing:
strategy: ${routingStrategy}
# =============================================================================
# Authentication
# =============================================================================
+1
View File
@@ -16,6 +16,7 @@ export type {
ChecksumResult,
DownloadResult,
CLIProxyProvider,
CliproxyRoutingStrategy,
CLIProxyConfig,
ExecutorConfig,
ProviderConfig,
+17
View File
@@ -16,6 +16,7 @@ import type {
GetModelDefinitionsResponse,
} from './management-api-types';
import { CLIPROXY_DEFAULT_PORT } from './config/port-manager';
import type { CliproxyRoutingStrategy } from './types';
/** Default timeout for management operations (longer than health check) */
const DEFAULT_TIMEOUT_MS = 5000;
@@ -227,6 +228,22 @@ export class ManagementApiClient {
return response.data?.models ?? [];
}
/**
* Get the global credential routing strategy from CLIProxy.
*/
async getRoutingStrategy(): Promise<CliproxyRoutingStrategy> {
const response = await this.request<{ strategy?: string }>('GET', '/routing/strategy');
return response.data?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
}
/**
* Update the global credential routing strategy on CLIProxy.
*/
async putRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise<CliproxyRoutingStrategy> {
await this.request('PUT', '/routing/strategy', { value: strategy });
return strategy;
}
/**
* Get a management section from CLIProxyAPI.
* Example sections: claude-api-key, gemini-api-key, codex-api-key.
+117
View File
@@ -0,0 +1,117 @@
import * as https from 'https';
import {
buildManagementHeaders,
buildProxyUrl,
getProxyTarget,
type ProxyTarget,
} from './proxy-target-resolver';
const ROUTING_TIMEOUT_MS = 5000;
export async function fetchCliproxyRoutingResponse(
target: ProxyTarget,
method: 'GET' | 'PUT',
body?: Record<string, string>
): Promise<Response> {
const url = buildProxyUrl(target, '/routing/strategy');
const headers = buildManagementHeaders(
target,
body ? { 'Content-Type': 'application/json' } : {}
);
if (target.protocol !== 'https' || !target.allowSelfSigned) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ROUTING_TIMEOUT_MS);
try {
return await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
}
return new Promise<Response>((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
callback();
};
const timeoutId = setTimeout(() => {
const error = new Error('Request timeout');
req.destroy(error);
settle(() => reject(error));
}, ROUTING_TIMEOUT_MS);
const req = https.request(
url,
{
method,
headers,
agent,
timeout: ROUTING_TIMEOUT_MS,
},
(res) => {
let payload = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
payload += chunk;
});
res.on('end', () => {
settle(() =>
resolve(
new Response(payload, {
status: res.statusCode || 500,
statusText: res.statusMessage ?? '',
headers:
typeof res.headers['content-type'] === 'string'
? { 'Content-Type': res.headers['content-type'] }
: undefined,
})
)
);
});
}
);
req.on('error', (error) => {
settle(() => reject(error));
});
req.on('timeout', () => {
const error = new Error('Request timeout');
req.destroy(error);
settle(() => reject(error));
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
export function getCliproxyRoutingTarget(): ProxyTarget {
return getProxyTarget();
}
export function getRoutingErrorMessage(response: Response, fallback: string): Promise<string> {
return response
.json()
.then((data) => {
if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') {
return data.error;
}
return fallback;
})
.catch(() => fallback);
}
+155
View File
@@ -0,0 +1,155 @@
import { mutateUnifiedConfig, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { regenerateConfig } from './config/generator';
import {
fetchCliproxyRoutingResponse,
getCliproxyRoutingTarget,
getRoutingErrorMessage,
} from './routing-strategy-http';
import type { CliproxyRoutingStrategy } from './types';
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export interface CliproxyRoutingState {
strategy: CliproxyRoutingStrategy;
source: 'live' | 'config';
target: 'local' | 'remote';
reachable: boolean;
message?: string;
}
export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
applied: 'live' | 'live-and-config' | 'config-only';
}
export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutingStrategy | null {
if (typeof value !== 'string') {
return null;
}
switch (value.trim().toLowerCase()) {
case 'round-robin':
case 'roundrobin':
case 'rr':
return 'round-robin';
case 'fill-first':
case 'fillfirst':
case 'ff':
return 'fill-first';
default:
return null;
}
}
export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy {
return (
normalizeCliproxyRoutingStrategy(loadOrCreateUnifiedConfig().cliproxy?.routing?.strategy) ??
DEFAULT_CLIPROXY_ROUTING_STRATEGY
);
}
export async function fetchLiveCliproxyRoutingStrategy(): Promise<CliproxyRoutingStrategy> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET');
if (!response.ok) {
throw new Error(
await getRoutingErrorMessage(response, `Failed to read routing strategy (${response.status})`)
);
}
const data = (await response.json()) as { strategy?: string };
const strategy = normalizeCliproxyRoutingStrategy(data?.strategy);
if (!strategy) {
throw new Error('CLIProxy returned an invalid routing strategy');
}
return strategy;
}
export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState> {
const target = getCliproxyRoutingTarget();
if (target.isRemote) {
return {
strategy: await fetchLiveCliproxyRoutingStrategy(),
source: 'live',
target: 'remote',
reachable: true,
};
}
try {
return {
strategy: await fetchLiveCliproxyRoutingStrategy(),
source: 'live',
target: 'local',
reachable: true,
};
} catch {
return {
strategy: getConfiguredCliproxyRoutingStrategy(),
source: 'config',
target: 'local',
reachable: false,
message: 'Local CLIProxy is not reachable. Showing the saved startup default.',
};
}
}
export async function applyCliproxyRoutingStrategy(
strategy: CliproxyRoutingStrategy
): Promise<CliproxyRoutingApplyResult> {
const target = getCliproxyRoutingTarget();
if (target.isRemote) {
await updateLiveCliproxyRoutingStrategy(strategy);
return {
strategy,
source: 'live',
target: 'remote',
reachable: true,
applied: 'live',
message: 'Updated remote CLIProxy routing strategy.',
};
}
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { strategy };
}
});
regenerateConfig(target.port);
try {
await updateLiveCliproxyRoutingStrategy(strategy);
return {
strategy,
source: 'live',
target: 'local',
reachable: true,
applied: 'live-and-config',
message: 'Updated the running proxy and saved the local startup default.',
};
} catch {
return {
strategy,
source: 'config',
target: 'local',
reachable: false,
applied: 'config-only',
message: 'Saved the local startup default. It will apply the next time CLIProxy starts.',
};
}
}
async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise<void> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', {
value: strategy,
});
if (!response.ok) {
throw new Error(
await getRoutingErrorMessage(
response,
`Failed to update routing strategy (${response.status})`
)
);
}
}
+8
View File
@@ -141,6 +141,11 @@ export type CLIProxyProvider =
*/
export type CLIProxyBackend = 'original' | 'plus';
/**
* Credential routing strategy for matching CLIProxy accounts.
*/
export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
/**
* Providers that require CLIProxyAPIPlus backend
*/
@@ -154,6 +159,9 @@ export interface CLIProxyConfig {
'api-keys': string[];
'auth-dir': string;
debug: boolean;
routing?: {
strategy?: CliproxyRoutingStrategy;
};
'gemini-api-key'?: Array<{
'api-key': string;
'base-url'?: string;
+3
View File
@@ -57,6 +57,9 @@ export async function showHelp(): Promise<void> {
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
['routing', 'Show current routing strategy and manual guidance'],
['routing explain', 'Explain round-robin vs fill-first'],
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
],
],
[
+15
View File
@@ -35,6 +35,7 @@ import {
} from './proxy-lifecycle-subcommand';
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
import { showHelp } from './help-subcommand';
import { handleRoutingStatus, handleRoutingExplain, handleRoutingSet } from './routing-subcommand';
import {
handleCatalogStatus,
handleCatalogRefresh,
@@ -174,6 +175,20 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
if (command === 'routing') {
const subcommand = remainingArgs[1];
if (subcommand === 'set') {
await handleRoutingSet(remainingArgs.slice(2));
return;
}
if (subcommand === 'explain') {
await handleRoutingExplain();
return;
}
await handleRoutingStatus();
return;
}
const commandHandlers: Record<string, () => Promise<void>> = {
create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend),
edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend),
@@ -0,0 +1,80 @@
import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui';
import {
applyCliproxyRoutingStrategy,
normalizeCliproxyRoutingStrategy,
readCliproxyRoutingState,
} from '../../cliproxy/routing-strategy';
function printStrategyGuide(): void {
console.log(subheader('Routing Modes:'));
console.log(` ${color('round-robin', 'command')} Spread requests across matching accounts.`);
console.log(` ${dim(' Best when you want even usage and predictable distribution.')}`);
console.log('');
console.log(` ${color('fill-first', 'command')} Drain one available account before moving on.`);
console.log(
` ${dim(' Best when you want backup accounts to stay cold until the active one hits a limit.')}`
);
console.log('');
console.log(
dim(
' Default stays round-robin. CCS will not switch strategy from your account mix automatically.'
)
);
console.log('');
}
export async function handleRoutingStatus(): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Routing Strategy'));
console.log('');
const state = await readCliproxyRoutingState();
console.log(` Current: ${color(state.strategy, 'command')}`);
console.log(` Target: ${color(state.target, 'info')}`);
console.log(
` Source: ${color(state.source === 'live' ? 'live CLIProxy' : 'saved startup default', 'info')}`
);
if (state.message) {
console.log('');
console.log(infoBox(state.message, state.reachable ? 'INFO' : 'WARNING'));
}
console.log('');
printStrategyGuide();
}
export async function handleRoutingExplain(): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Routing Guide'));
console.log('');
printStrategyGuide();
}
export async function handleRoutingSet(args: string[]): Promise<void> {
const requested = normalizeCliproxyRoutingStrategy(args[0]);
if (!requested) {
await initUI();
console.log('');
console.log(fail('Invalid strategy. Use: round-robin or fill-first'));
console.log('');
printStrategyGuide();
process.exitCode = 1;
return;
}
await initUI();
console.log('');
console.log(header('Update CLIProxy Routing'));
console.log('');
const result = await applyCliproxyRoutingStrategy(requested);
console.log(ok(`Routing strategy set to ${requested}`));
console.log(` Applied: ${color(result.applied, 'info')}`);
console.log(` Target: ${color(result.target, 'info')}`);
if (result.message) {
console.log('');
console.log(infoBox(result.message, result.reachable ? 'SUCCESS' : 'INFO'));
}
console.log('');
}
+1
View File
@@ -237,6 +237,7 @@ export const CLIPROXY_SUBCOMMANDS = [
'edit',
'list',
'remove',
'routing',
'catalog',
'sync',
'quota',
+6
View File
@@ -157,6 +157,12 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
'--help',
'-h',
]);
if (subcommand === 'routing') {
if (lastToken === 'set') {
return completeSubcommands(['round-robin', 'fill-first']);
}
return completeSubcommands(['set', 'explain']);
}
if (['remove', 'edit'].includes(subcommand)) {
return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']);
}
+4 -1
View File
@@ -211,7 +211,10 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
{ name: 'ccs cliproxy --help', summary: 'Deep help for variants, quota, and lifecycle' },
{
name: 'ccs cliproxy --help',
summary: 'Deep help for variants, routing, quota, and lifecycle',
},
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
{ name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' },
+7
View File
@@ -373,6 +373,13 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
strategy:
partial.cliproxy?.routing?.strategy === 'fill-first' ||
partial.cliproxy?.routing?.strategy === 'round-robin'
? partial.cliproxy.routing.strategy
: defaults.cliproxy.routing?.strategy,
},
},
preferences: {
...defaults.preferences,
+11 -1
View File
@@ -10,7 +10,7 @@
*/
import type { TargetType } from '../targets/target-adapter';
import type { CLIProxyProvider } from '../cliproxy/types';
import type { CLIProxyProvider, CliproxyRoutingStrategy } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
/**
@@ -201,6 +201,11 @@ export interface TokenRefreshSettings {
verbose?: boolean;
}
export interface CLIProxyRoutingConfig {
/** Credential selection strategy when multiple accounts match */
strategy?: CliproxyRoutingStrategy;
}
/**
* CLIProxy configuration section.
*/
@@ -225,6 +230,8 @@ export interface CLIProxyConfig {
token_refresh?: TokenRefreshSettings;
/** Auto-sync API profiles to local CLIProxy config on settings change (default: true) */
auto_sync?: boolean;
/** Routing strategy for multi-account CLIProxy selection */
routing?: CLIProxyRoutingConfig;
}
/**
@@ -901,6 +908,9 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
},
safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG },
auto_sync: true,
routing: {
strategy: 'round-robin',
},
},
preferences: {
theme: 'system',
@@ -0,0 +1,45 @@
import { Router, Request, Response } from 'express';
import {
applyCliproxyRoutingStrategy,
normalizeCliproxyRoutingStrategy,
readCliproxyRoutingState,
} from '../../cliproxy/routing-strategy';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy routing endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
router.get('/routing/strategy', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await readCliproxyRoutingState());
} catch (error) {
res.status(502).json({ error: (error as Error).message });
}
});
router.put('/routing/strategy', async (req: Request, res: Response): Promise<void> => {
const strategy = normalizeCliproxyRoutingStrategy(req.body?.value ?? req.body?.strategy);
if (!strategy) {
res.status(400).json({ error: 'Invalid strategy. Use: round-robin or fill-first' });
return;
}
try {
res.json(await applyCliproxyRoutingStrategy(strategy));
} catch (error) {
res.status(502).json({ error: (error as Error).message });
}
});
export default router;
+2
View File
@@ -21,6 +21,7 @@ import websearchRoutes from './websearch-routes';
import imageAnalysisRoutes from './image-analysis-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
import cliproxySyncRoutes from './cliproxy-sync-routes';
import aiProviderRoutes from './ai-provider-routes';
import copilotRoutes from './copilot-routes';
@@ -84,6 +85,7 @@ apiRoutes.use('/claude-extension', claudeExtensionRoutes);
// ==================== CLIProxy ====================
// Variants, auth, accounts, stats, status, models, error logs
apiRoutes.use('/cliproxy', cliproxyRoutingRoutes);
apiRoutes.use('/cliproxy', variantRoutes);
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);