feat(cliproxy): add routing guidance and strategy controls

This commit is contained in:
Tam Nhu Tran
2026-04-07 18:17:06 -04:00
parent 70665201f0
commit 6393249111
33 changed files with 1304 additions and 27 deletions
+3 -1
View File
@@ -82,7 +82,9 @@ config. Deep dive:
![CLIProxy API](assets/screenshots/cliproxyapi.webp)
Manage OAuth-backed providers, quota visibility, and routing from one place.
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.
Deep dive:
[CLIProxy API](https://docs.ccs.kaitran.ca/features/proxy/cliproxy-api).
+6 -1
View File
@@ -1,6 +1,6 @@
# CCS Product Development Requirements (PDR)
Last Updated: 2026-04-02
Last Updated: 2026-04-07
## Product Overview
@@ -39,6 +39,7 @@ CCS provides:
7. **Automatic Image Analysis**: First-class local ImageAnalysis tool with direct provider routing for third-party profiles
8. **Usage Analytics**: Token tracking, cost analysis, model breakdown
9. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage
10. **Routing Strategy Guidance**: First-class `round-robin` vs `fill-first` controls in CLI and dashboard, with explicit opt-in changes and no account-based guessing
---
@@ -118,6 +119,10 @@ CCS provides:
### FR-009: Quota Management (v7.14)
- Pause/resume individual accounts via `ccs cliproxy pause/resume <account>`
- Check quota status via `ccs cliproxy status [account]`
- Inspect the current proxy-wide routing strategy via `ccs cliproxy routing`
- Explicitly switch `round-robin` vs `fill-first` from CLI or dashboard
- Keep `round-robin` as the default until the user explicitly changes it
- Never infer routing strategy from account count, tier mix, or paused/default account state
- Auto-failover when account exhausted
- Tier detection: free/paid/unknown
- Pre-flight quota checks before session start
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-05
Last Updated: 2026-04-07
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition.
- **2026-04-06**: The dashboard login surface now distinguishes a real sign-in from a host-setup requirement. Remote/IP visitors no longer see a misleading blank credential form when dashboard auth is disabled or incomplete; they now get explicit guidance that CCS has no default credentials, should be enabled on the host with `ccs config auth setup`, or should be reopened via localhost when used on the same machine. The password field now includes a show/hide toggle, and the page exposes an explicit light/dark theme switch before sign-in.
- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough.
- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`.
+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);
@@ -90,6 +90,54 @@ describe('management-api-client', () => {
});
});
describe('routing strategy helpers', () => {
it('reads the routing strategy from the management endpoint', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ strategy: 'fill-first' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
);
global.fetch = fetchMock as typeof global.fetch;
const strategy = await client.getRoutingStrategy();
expect(strategy).toBe('fill-first');
expect(fetchMock).toHaveBeenCalled();
global.fetch = originalFetch;
});
it('writes the routing strategy using the expected payload shape', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
);
global.fetch = fetchMock as typeof global.fetch;
const strategy = await client.putRoutingStrategy('round-robin');
expect(strategy).toBe('round-robin');
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8317/routing/strategy',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ value: 'round-robin' }),
})
);
global.fetch = originalFetch;
});
});
describe('error code mapping', () => {
it('should map ENOTFOUND to DNS_FAILED', () => {
const error = new Error('getaddrinfo ENOTFOUND example.com') as NodeJS.ErrnoException;
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
describe('cliproxy routing strategy service', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let setGlobalConfigDir: (dir: string | undefined) => void;
let routingTarget = {
host: '127.0.0.1',
port: 8317,
protocol: 'http' as const,
isRemote: false,
};
let responseFactory: (() => Promise<Response>) | null = null;
beforeEach(async () => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-'));
process.env.CCS_HOME = tempHome;
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
setGlobalConfigDir(path.join(tempHome, '.ccs'));
});
afterEach(() => {
mock.restore();
setGlobalConfigDir(undefined);
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
async function loadRoutingModule() {
mock.module('../../../src/cliproxy/routing-strategy-http', () => ({
getCliproxyRoutingTarget: () => routingTarget,
fetchCliproxyRoutingResponse: () => {
if (!responseFactory) {
throw new Error('routing unavailable');
}
return responseFactory();
},
getRoutingErrorMessage: async (response: Response, fallback: string) => {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return body?.error || fallback;
},
}));
return import(`../../../src/cliproxy/routing-strategy?test=${Date.now()}-${Math.random()}`);
}
it('normalizes canonical and shorthand strategy values', async () => {
const mod = await loadRoutingModule();
expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin');
expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin');
expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first');
expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first');
expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull();
});
it('falls back to the saved local default when live CLIProxy is unavailable', async () => {
const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { strategy: 'fill-first' };
}
});
const mod = await loadRoutingModule();
const state = await mod.readCliproxyRoutingState();
expect(state.strategy).toBe('fill-first');
expect(state.source).toBe('config');
expect(state.target).toBe('local');
expect(state.reachable).toBe(false);
});
it('persists the local startup default even when the live proxy is down', async () => {
const mod = await loadRoutingModule();
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
expect(result.applied).toBe('config-only');
expect(result.strategy).toBe('fill-first');
const configPath = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml');
const configContent = fs.readFileSync(configPath, 'utf8');
expect(configContent).toContain('routing:');
expect(configContent).toContain('strategy: fill-first');
});
it('reads and writes remote strategy without mutating the local default', async () => {
routingTarget = {
host: 'remote.example.com',
port: 8080,
protocol: 'http',
isRemote: true,
};
let methodCount = 0;
responseFactory = async () => {
methodCount += 1;
return new Response(JSON.stringify({ strategy: 'fill-first' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const mod = await loadRoutingModule();
const readState = await mod.readCliproxyRoutingState();
const writeState = await mod.applyCliproxyRoutingStrategy('fill-first');
expect(readState.strategy).toBe('fill-first');
expect(readState.target).toBe('remote');
expect(writeState.applied).toBe('live');
expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin');
expect(methodCount).toBe(2);
});
});
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
describe('cliproxy routing command dispatch', () => {
let calls: string[] = [];
beforeEach(() => {
calls = [];
mock.module('../../../src/commands/cliproxy/routing-subcommand', () => ({
handleRoutingStatus: async () => {
calls.push('status');
},
handleRoutingExplain: async () => {
calls.push('explain');
},
handleRoutingSet: async (args: string[]) => {
calls.push(`set:${args.join(' ')}`);
},
}));
});
afterEach(() => {
mock.restore();
});
async function loadHandleCliproxyCommand() {
const mod = await import(`../../../src/commands/cliproxy/index?test=${Date.now()}-${Math.random()}`);
return mod.handleCliproxyCommand;
}
it('shows routing status by default', async () => {
const handleCliproxyCommand = await loadHandleCliproxyCommand();
await handleCliproxyCommand(['routing']);
expect(calls).toEqual(['status']);
});
it('shows the routing explainer', async () => {
const handleCliproxyCommand = await loadHandleCliproxyCommand();
await handleCliproxyCommand(['routing', 'explain']);
expect(calls).toEqual(['explain']);
});
it('passes the explicit strategy to set', async () => {
const handleCliproxyCommand = await loadHandleCliproxyCommand();
await handleCliproxyCommand(['routing', 'set', 'fill-first']);
expect(calls).toEqual(['set:fill-first']);
});
});
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import express from 'express';
import type { Server } from 'http';
describe('cliproxy routing routes', () => {
let server: Server;
let baseUrl = '';
let readStateMock: ReturnType<typeof mock>;
let applyStrategyMock: ReturnType<typeof mock>;
beforeEach(async () => {
readStateMock = mock(async () => ({
strategy: 'round-robin',
source: 'live',
target: 'local',
reachable: true,
}));
applyStrategyMock = mock(async () => ({
strategy: 'fill-first',
source: 'live',
target: 'local',
reachable: true,
applied: 'live-and-config',
}));
mock.module('../../../src/cliproxy/routing-strategy', () => ({
readCliproxyRoutingState: readStateMock,
applyCliproxyRoutingStrategy: applyStrategyMock,
normalizeCliproxyRoutingStrategy: (value: unknown) => {
if (value === 'round-robin' || value === 'fill-first') {
return value;
}
return null;
},
}));
const { default: routingRoutes } = await import(
`../../../src/web-server/routes/cliproxy-routing-routes?test=${Date.now()}-${Math.random()}`
);
const app = express();
app.use(express.json());
app.use('/api/cliproxy', routingRoutes);
server = await new Promise<Server>((resolve, reject) => {
const instance = app.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterEach(async () => {
mock.restore();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('returns the current routing state', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/routing/strategy`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
strategy: 'round-robin',
source: 'live',
target: 'local',
reachable: true,
});
expect(readStateMock).toHaveBeenCalledTimes(1);
});
it('rejects invalid routing values', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/routing/strategy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: 'auto' }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: 'Invalid strategy. Use: round-robin or fill-first',
});
expect(applyStrategyMock).not.toHaveBeenCalled();
});
it('applies a valid routing strategy', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/routing/strategy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: 'fill-first' }),
});
expect(response.status).toBe(200);
expect(applyStrategyMock).toHaveBeenCalledWith('fill-first');
expect(await response.json()).toEqual({
strategy: 'fill-first',
source: 'live',
target: 'local',
reachable: true,
applied: 'live-and-config',
});
});
});
@@ -8,6 +8,7 @@ import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility
interface AccountSafetyWarningCardProps {
className?: string;
compact?: boolean;
showAcknowledgement?: boolean;
acknowledgementPhrase?: string;
acknowledgementText?: string;
@@ -18,6 +19,7 @@ interface AccountSafetyWarningCardProps {
export function AccountSafetyWarningCard({
className,
compact = false,
showAcknowledgement = false,
acknowledgementPhrase = RISK_ACK_PHRASE,
acknowledgementText = '',
@@ -34,50 +36,126 @@ export function AccountSafetyWarningCard({
const issueLabel = t('accountSafetyWarning.issueLabel');
const proxySettingsLabel = t('accountSafetyWarning.proxySettingsLabel');
if (compact) {
return (
<section
role="alert"
className={cn('border-b border-amber-200/70 bg-amber-50/45 dark:bg-amber-950/5', className)}
>
<div className="flex flex-col gap-3 px-6 py-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex items-start gap-3">
<div className="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-md bg-amber-500/12 text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold leading-5">{title}</p>
<p className="text-[11px] text-muted-foreground">{subtitle}</p>
</div>
<p className="text-xs leading-5 text-muted-foreground">{firstLine}</p>
<p className="text-xs font-medium leading-5 text-amber-900 dark:text-amber-200">
{secondLine}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<a
href={issueUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-amber-500/25 bg-background/90 px-2 py-1 text-[11px] font-medium text-amber-800 transition-colors hover:bg-amber-500/10 dark:text-amber-200"
>
{issueLabel}
<ExternalLink className="h-3 w-3" />
</a>
{showProxySettingsLink ? (
<a
href="/settings?tab=proxy"
className="inline-flex items-center gap-1 rounded-md border border-amber-500/25 bg-background/90 px-2 py-1 text-[11px] font-medium text-amber-800 transition-colors hover:bg-amber-500/10 dark:text-amber-200"
>
<Settings2 className="h-3 w-3" />
{proxySettingsLabel}
</a>
) : null}
<Badge
variant="outline"
className="border-amber-500/40 text-[10px] text-amber-700 dark:text-amber-300"
>
High Risk
</Badge>
</div>
</div>
</section>
);
}
return (
<section
role="alert"
className={cn(
'relative overflow-hidden rounded-xl border border-amber-500/30 bg-gradient-to-br from-amber-50 via-background to-rose-50/70 shadow-sm dark:from-amber-950/20 dark:to-rose-950/20',
compact &&
'rounded-lg border-amber-400/25 bg-gradient-to-r from-amber-50/90 via-background to-background shadow-none dark:from-amber-950/10 dark:to-background',
className
)}
>
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500" />
<div className="space-y-3 p-4">
<div className={cn('space-y-3 p-4', compact && 'space-y-2 px-3 py-2.5')}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-md bg-amber-500/15 text-amber-700 dark:text-amber-400">
<div
className={cn(
'mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-md bg-amber-500/15 text-amber-700 dark:text-amber-400',
compact && 'h-6 w-6 rounded-lg'
)}
>
<AlertTriangle className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold leading-5">{title}</p>
<p className={cn('text-sm font-semibold leading-5', compact && 'text-xs')}>{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
</div>
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
className={cn(
'border-amber-500/40 text-amber-700 dark:text-amber-300',
compact && 'h-5 px-1.5 text-[10px]'
)}
>
High Risk
</Badge>
</div>
<div className="space-y-2 text-sm leading-relaxed">
<p>{firstLine}</p>
<p className="font-medium text-amber-900 dark:text-amber-200">{secondLine}</p>
<p className="text-xs text-muted-foreground">
CCS is provided as-is and does not take responsibility for suspension, bans, or access
loss from upstream providers.
</p>
</div>
{compact ? (
<div className="space-y-1.5">
<p className="text-xs leading-5 text-muted-foreground">{firstLine}</p>
<p className="text-xs font-medium leading-5 text-amber-900 dark:text-amber-200">
{secondLine}
</p>
</div>
) : (
<div className="space-y-2 text-sm leading-relaxed">
<p>{firstLine}</p>
<p className="font-medium text-amber-900 dark:text-amber-200">{secondLine}</p>
<p className="text-xs text-muted-foreground">
CCS is provided as-is and does not take responsibility for suspension, bans, or access
loss from upstream providers.
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<a
href={issueUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200',
compact && 'px-2 py-0.5 text-[11px]'
)}
>
{issueLabel}
<ExternalLink className="h-3.5 w-3.5" />
@@ -85,13 +163,21 @@ export function AccountSafetyWarningCard({
{showProxySettingsLink && (
<a
href="/settings?tab=proxy"
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200',
compact && 'px-2 py-0.5 text-[11px]'
)}
>
<Settings2 className="h-3.5 w-3.5" />
{proxySettingsLabel}
</a>
)}
<span className="rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground">
<span
className={cn(
'rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground',
compact && 'px-2 py-0.5 text-[11px]'
)}
>
Applies to CLI and dashboard auth
</span>
</div>
+1
View File
@@ -12,6 +12,7 @@ export { CliproxyTabs } from './cliproxy-tabs';
export { ControlPanelEmbed } from './control-panel-embed';
export { ProviderLogo } from './provider-logo';
export { ProviderModelSelector } from './provider-model-selector';
export { RoutingGuidanceCard } from './routing-guidance-card';
// Provider editor (from subdirectory)
export { ProviderEditor } from './provider-editor';
@@ -38,6 +38,7 @@ export function ProviderEditor({
isRemoteMode,
port,
defaultTarget,
topNotice,
onAddAccount,
onSetDefault,
onRemoveAccount,
@@ -227,6 +228,7 @@ export function ProviderEditor({
onRefetch={refetch}
onSave={() => saveMutation.mutate()}
/>
{topNotice ? <div className="border-b bg-muted/10 px-4 py-3">{topNotice}</div> : null}
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
@@ -2,6 +2,7 @@
* Type definitions for ProviderEditor components
*/
import type { ReactNode } from 'react';
import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client';
import type { ProviderCatalog } from '../provider-model-selector';
@@ -29,6 +30,8 @@ export interface ProviderEditorProps {
port?: number;
/** Default execution target for this profile/variant */
defaultTarget?: CliTarget;
/** Optional contextual notice shown directly under the editor header */
topNotice?: ReactNode;
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
@@ -0,0 +1,237 @@
import { useState } from 'react';
import { ArrowRightLeft, ChevronDown, ChevronUp } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { CliproxyRoutingState, RoutingStrategy } from '@/lib/api-client';
import { cn } from '@/lib/utils';
interface RoutingGuidanceCardProps {
className?: string;
compact?: boolean;
state?: CliproxyRoutingState;
isLoading: boolean;
isSaving: boolean;
error?: Error | null;
onApply: (strategy: RoutingStrategy) => void;
}
const STRATEGY_COPY: Record<RoutingStrategy, { title: string; description: string }> = {
'round-robin': {
title: 'Round Robin',
description: 'Spread requests across matching accounts for even usage.',
},
'fill-first': {
title: 'Fill First',
description: 'Drain one healthy account first and keep backups untouched until needed.',
},
};
export function RoutingGuidanceCard({
className,
compact = false,
state,
isLoading,
isSaving,
error,
onApply,
}: RoutingGuidanceCardProps) {
const currentStrategy = state?.strategy ?? 'round-robin';
const [selected, setSelected] = useState<RoutingStrategy>(currentStrategy);
const [detailsOpen, setDetailsOpen] = useState(false);
const sourceLabel = state?.source === 'live' ? 'Live CLIProxy' : 'Saved startup default';
const saveDisabled = isLoading || isSaving || !state || selected === currentStrategy;
const detailToggleLabel = detailsOpen ? 'Hide details' : 'Show details';
if (compact) {
const statusLine = selected === 'fill-first' ? 'One account first' : 'Balanced usage';
return (
<section className={cn('border-t border-border/60 pt-3', className)}>
<div className="space-y-2.5 rounded-xl border border-border/70 bg-background/90 p-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-muted/80 text-primary transition-transform duration-200 hover:scale-105">
<ArrowRightLeft className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Routing
</span>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{currentStrategy}
</Badge>
</div>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{statusLine}</p>
</div>
</div>
{!state?.reachable ? (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
Saved default
</Badge>
) : null}
</div>
<div className="relative grid grid-cols-2 gap-1 rounded-xl border border-border/70 bg-muted/30 p-1">
<div
className={cn(
'absolute inset-y-1 left-1 w-[calc(50%-0.375rem)] rounded-lg bg-background shadow-[0_1px_0_rgba(255,255,255,0.9)_inset,0_6px_16px_rgba(15,23,42,0.06)] transition-transform duration-300 ease-out',
selected === 'fill-first' ? 'translate-x-[calc(100%+0.25rem)]' : 'translate-x-0'
)}
/>
{(
Object.entries(STRATEGY_COPY) as Array<
[RoutingStrategy, { title: string; description: string }]
>
).map(([strategy, copy]) => {
const active = selected === strategy;
return (
<button
key={strategy}
type="button"
className={cn(
'relative z-10 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors duration-200 active:scale-[0.98]',
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setSelected(strategy)}
disabled={isLoading || isSaving || !!error}
title={copy.description}
>
{copy.title}
</button>
);
})}
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
<span>Proxy-wide</span>
{state?.source === 'config' ? (
<>
<span className="text-border"></span>
<span>Local</span>
</>
) : null}
</div>
<Button
size="sm"
className="h-7 px-2.5 text-[11px] transition-transform duration-200 hover:-translate-y-px active:translate-y-0"
onClick={() => onApply(selected)}
disabled={saveDisabled || !!error}
>
{isSaving ? 'Saving...' : 'Apply'}
</Button>
</div>
</div>
</section>
);
}
return (
<section className={cn('rounded-xl border border-border/70 bg-background', className)}>
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-muted text-primary">
<ArrowRightLeft className="h-4 w-4" />
</div>
<div className="text-sm font-medium">Routing strategy</div>
<Badge variant="secondary">{currentStrategy}</Badge>
{state ? <Badge variant="outline">{sourceLabel}</Badge> : null}
{state ? <Badge variant="outline">{state.target}</Badge> : null}
</div>
<p className="max-w-3xl text-xs leading-5 text-muted-foreground">
Proxy-wide account rotation. CCS keeps round-robin as the default until you explicitly
change it.
</p>
</div>
<div className="flex flex-col gap-2 xl:items-end">
<div className="inline-flex flex-wrap rounded-lg border border-border/70 bg-muted/35 p-1">
{(
Object.entries(STRATEGY_COPY) as Array<
[RoutingStrategy, { title: string; description: string }]
>
).map(([strategy, copy]) => {
const active = selected === strategy;
return (
<button
key={strategy}
type="button"
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setSelected(strategy)}
disabled={isLoading || isSaving || !!error}
>
{copy.title}
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
onClick={() => setDetailsOpen((open) => !open)}
>
{detailsOpen ? (
<ChevronUp className="mr-1 h-3.5 w-3.5" />
) : (
<ChevronDown className="mr-1 h-3.5 w-3.5" />
)}
{detailToggleLabel}
</Button>
<Button size="sm" onClick={() => onApply(selected)} disabled={saveDisabled || !!error}>
{isSaving ? 'Saving...' : `Use ${selected}`}
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground xl:col-span-2">
<span>Round robin spreads usage.</span>
<span className="hidden text-border sm:inline"></span>
<span>Fill first keeps backup accounts cold until they are needed.</span>
</div>
{error ? (
<div className="rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm xl:col-span-2">
{error.message}
</div>
) : null}
{!error && state?.message ? (
<div className="rounded-lg border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground xl:col-span-2">
{state.message}
</div>
) : null}
{detailsOpen ? (
<div className="grid gap-4 border-t border-border/60 pt-3 md:grid-cols-2 xl:col-span-2">
{(
Object.entries(STRATEGY_COPY) as Array<
[RoutingStrategy, { title: string; description: string }]
>
).map(([strategy, copy]) => {
const current = currentStrategy === strategy;
return (
<div key={strategy} className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{copy.title}</div>
{current ? <Badge variant="secondary">Current</Badge> : null}
</div>
<p className="text-xs leading-5 text-muted-foreground">{copy.description}</p>
</div>
);
})}
</div>
) : null}
</div>
</section>
);
}
@@ -60,6 +60,8 @@ import {
useCliproxyVersions,
useInstallVersion,
useRestartProxy,
useCliproxyRoutingStrategy,
useUpdateCliproxyRoutingStrategy,
} from '@/hooks/use-cliproxy';
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
import { cn } from '@/lib/utils';
@@ -67,6 +69,7 @@ import {
isCliproxyVersionExperimental,
isCliproxyVersionInRange,
} from '@/lib/cliproxy-version-risk';
import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card';
type PendingInstallRisk = 'faulty' | 'experimental';
@@ -150,6 +153,12 @@ export function ProxyStatusWidget() {
const { data: status, isLoading } = useProxyStatus();
const { data: updateCheck } = useCliproxyUpdateCheck();
const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions();
const {
data: routingState,
isLoading: routingLoading,
error: routingError,
} = useCliproxyRoutingStrategy();
const updateRouting = useUpdateCliproxyRoutingStrategy();
const startProxy = useStartProxy();
const stopProxy = useStopProxy();
const restartProxy = useRestartProxy();
@@ -304,6 +313,17 @@ export function ProxyStatusWidget() {
{t('proxyStatusWidget.trafficAutoRouted')}
</p>
</div>
<RoutingGuidanceCard
key={`remote:${routingState?.strategy ?? 'round-robin'}`}
compact
className="mt-3"
state={routingState}
isLoading={routingLoading}
isSaving={updateRouting.isPending}
error={routingError instanceof Error ? routingError : null}
onApply={(strategy) => updateRouting.mutate(strategy)}
/>
</div>
);
}
@@ -450,6 +470,17 @@ export function ProxyStatusWidget() {
</span>
</div>
<RoutingGuidanceCard
key={`local:${routingState?.strategy ?? 'round-robin'}`}
compact
className="mt-3"
state={routingState}
isLoading={routingLoading}
isSaving={updateRouting.isPending}
error={routingError instanceof Error ? routingError : null}
onApply={(strategy) => updateRouting.mutate(strategy)}
/>
{/* Expanded section: Version Management (available even when not running) */}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
+29 -1
View File
@@ -5,7 +5,13 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type CreateVariant, type UpdateVariant, type CreatePreset } from '@/lib/api-client';
import {
api,
type CreateVariant,
type UpdateVariant,
type CreatePreset,
type RoutingStrategy,
} from '@/lib/api-client';
import { toast } from 'sonner';
export function useCliproxy() {
@@ -22,6 +28,28 @@ export function useCliproxyAuth() {
});
}
export function useCliproxyRoutingStrategy() {
return useQuery({
queryKey: ['cliproxy-routing'],
queryFn: () => api.cliproxy.getRoutingStrategy(),
});
}
export function useUpdateCliproxyRoutingStrategy() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (strategy: RoutingStrategy) => api.cliproxy.updateRoutingStrategy(strategy),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-routing'] });
toast.success(result.message || `Routing strategy set to ${result.strategy}`);
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useCreateVariant() {
const queryClient = useQueryClient();
+20
View File
@@ -413,6 +413,20 @@ export interface AuthStatus {
defaultAccount?: string;
}
export type RoutingStrategy = 'round-robin' | 'fill-first';
export interface CliproxyRoutingState {
strategy: RoutingStrategy;
source: 'live' | 'config';
target: 'local' | 'remote';
reachable: boolean;
message?: string;
}
export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
applied: 'live' | 'live-and-config' | 'config-only';
}
/** Auth file info for Config tab */
export interface AuthFile {
name: string;
@@ -991,6 +1005,12 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ model }),
}),
getRoutingStrategy: () => request<CliproxyRoutingState>('/cliproxy/routing/strategy'),
updateRoutingStrategy: (strategy: RoutingStrategy) =>
request<CliproxyRoutingApplyResult>('/cliproxy/routing/strategy', {
method: 'PUT',
body: JSON.stringify({ value: strategy }),
}),
aiProviders: {
list: () => request<ListAiProvidersResult>('/cliproxy/ai-providers'),
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
+11 -5
View File
@@ -443,11 +443,7 @@ export function CliproxyPage() {
</div>
{/* Right Panel */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{showAccountSafetyWarning && (
<AccountSafetyWarningCard showProxySettingsLink className="mx-4 mt-4" />
)}
<div className="flex-1 flex min-w-0 flex-col overflow-hidden bg-background">
{selectedVariantData && parentAuthForVariant ? (
<>
<ProviderEditor
@@ -463,6 +459,11 @@ export function CliproxyPage() {
defaultTarget={selectedVariantData.target}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
topNotice={
showAccountSafetyWarning ? (
<AccountSafetyWarningCard compact showProxySettingsLink />
) : undefined
}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider,
@@ -507,6 +508,11 @@ export function CliproxyPage() {
authStatus={selectedStatus}
catalog={MODEL_CATALOGS[selectedStatus.provider]}
isRemoteMode={isRemoteMode}
topNotice={
showAccountSafetyWarning ? (
<AccountSafetyWarningCard compact showProxySettingsLink />
) : undefined
}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../setup/test-utils';
import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card';
describe('RoutingGuidanceCard', () => {
it('shows the current strategy and applies an explicit change', async () => {
const onApply = vi.fn();
render(
<RoutingGuidanceCard
state={{
strategy: 'round-robin',
source: 'live',
target: 'local',
reachable: true,
}}
isLoading={false}
isSaving={false}
onApply={onApply}
/>
);
expect(screen.getByText('Routing strategy')).toBeInTheDocument();
expect(screen.getAllByText('round-robin').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: /fill first/i }));
fireEvent.click(screen.getByRole('button', { name: /use fill-first/i }));
expect(onApply).toHaveBeenCalledWith('fill-first');
});
it('shows the error state and disables apply', () => {
render(
<RoutingGuidanceCard
isLoading={false}
isSaving={false}
error={new Error('Remote CLIProxy is not reachable')}
onApply={() => undefined}
/>
);
expect(screen.getByText('Remote CLIProxy is not reachable')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /use round-robin/i })).toBeDisabled();
});
});