mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
Merge pull request #925 from kaitranntt/kai/feat/cliproxy-routing-guidance
feat(cliproxy): add routing guidance and strategy controls
This commit is contained in:
33 files changed
+1237
-12
No files matched your search
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
ChecksumResult,
|
||||
DownloadResult,
|
||||
CLIProxyProvider,
|
||||
CliproxyRoutingStrategy,
|
||||
CLIProxyConfig,
|
||||
ExecutorConfig,
|
||||
ProviderConfig,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -237,6 +237,7 @@ export const CLIPROXY_SUBCOMMANDS = [
|
||||
'edit',
|
||||
'list',
|
||||
'remove',
|
||||
'routing',
|
||||
'catalog',
|
||||
'sync',
|
||||
'quota',
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user