From 6393249111acb54e1d388411c3349dd8ad35267c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 7 Apr 2026 18:17:06 -0400 Subject: [PATCH 1/3] feat(cliproxy): add routing guidance and strategy controls --- README.md | 4 +- docs/project-overview-pdr.md | 7 +- docs/project-roadmap.md | 3 +- src/cliproxy/config/generator.ts | 13 +- src/cliproxy/index.ts | 1 + src/cliproxy/management-api-client.ts | 17 ++ src/cliproxy/routing-strategy-http.ts | 117 +++++++++ src/cliproxy/routing-strategy.ts | 155 ++++++++++++ src/cliproxy/types.ts | 8 + src/commands/cliproxy/help-subcommand.ts | 3 + src/commands/cliproxy/index.ts | 15 ++ src/commands/cliproxy/routing-subcommand.ts | 80 ++++++ src/commands/command-catalog.ts | 1 + src/commands/completion-backend.ts | 6 + src/commands/help-command.ts | 5 +- src/config/unified-config-loader.ts | 7 + src/config/unified-config-types.ts | 12 +- .../routes/cliproxy-routing-routes.ts | 45 ++++ src/web-server/routes/index.ts | 2 + .../cliproxy/management-api-client.test.ts | 48 ++++ tests/unit/cliproxy/routing-strategy.test.ts | 127 ++++++++++ .../cliproxy-routing-subcommand.test.ts | 48 ++++ .../cliproxy-routing-routes.test.ts | 106 ++++++++ .../account/account-safety-warning-card.tsx | 116 +++++++-- ui/src/components/cliproxy/index.ts | 1 + .../cliproxy/provider-editor/index.tsx | 2 + .../cliproxy/provider-editor/types.ts | 3 + .../cliproxy/routing-guidance-card.tsx | 237 ++++++++++++++++++ .../monitoring/proxy-status-widget.tsx | 31 +++ ui/src/hooks/use-cliproxy.ts | 30 ++- ui/src/lib/api-client.ts | 20 ++ ui/src/pages/cliproxy.tsx | 16 +- .../cliproxy/routing-guidance-card.test.tsx | 45 ++++ 33 files changed, 1304 insertions(+), 27 deletions(-) create mode 100644 src/cliproxy/routing-strategy-http.ts create mode 100644 src/cliproxy/routing-strategy.ts create mode 100644 src/commands/cliproxy/routing-subcommand.ts create mode 100644 src/web-server/routes/cliproxy-routing-routes.ts create mode 100644 tests/unit/cliproxy/routing-strategy.test.ts create mode 100644 tests/unit/commands/cliproxy-routing-subcommand.test.ts create mode 100644 tests/unit/web-server/cliproxy-routing-routes.test.ts create mode 100644 ui/src/components/cliproxy/routing-guidance-card.tsx create mode 100644 ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx diff --git a/README.md b/README.md index 9fcd2cd6..cb981b5b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index a9166ffd..6563c273 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -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 ` - 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 diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index a895fe83..7671480e 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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`. diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index fc79958e..815b81b7 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -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 # ============================================================================= diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index aef15b13..a2862c51 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -16,6 +16,7 @@ export type { ChecksumResult, DownloadResult, CLIProxyProvider, + CliproxyRoutingStrategy, CLIProxyConfig, ExecutorConfig, ProviderConfig, diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 7afff06f..dc9de153 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -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 { + 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 { + 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. diff --git a/src/cliproxy/routing-strategy-http.ts b/src/cliproxy/routing-strategy-http.ts new file mode 100644 index 00000000..33d7b070 --- /dev/null +++ b/src/cliproxy/routing-strategy-http.ts @@ -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 +): Promise { + 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((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 { + return response + .json() + .then((data) => { + if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') { + return data.error; + } + return fallback; + }) + .catch(() => fallback); +} diff --git a/src/cliproxy/routing-strategy.ts b/src/cliproxy/routing-strategy.ts new file mode 100644 index 00000000..6adb15ff --- /dev/null +++ b/src/cliproxy/routing-strategy.ts @@ -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 { + 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 { + 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 { + 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 { + const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', { + value: strategy, + }); + if (!response.ok) { + throw new Error( + await getRoutingErrorMessage( + response, + `Failed to update routing strategy (${response.status})` + ) + ); + } +} diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 7f122cad..77462b0e 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -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; diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index cc3e60a2..9e1eae44 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -57,6 +57,9 @@ export async function showHelp(): Promise { ['resume ', 'Resume paused account'], ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], ['quota --provider ', `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 ', 'Explicitly set round-robin or fill-first'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 972c692e..50c9013a 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -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 { 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 Promise> = { create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend), edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend), diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts new file mode 100644 index 00000000..f78aed21 --- /dev/null +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -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 { + 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 { + await initUI(); + console.log(''); + console.log(header('CLIProxy Routing Guide')); + console.log(''); + printStrategyGuide(); +} + +export async function handleRoutingSet(args: string[]): Promise { + 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(''); +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 11f3a984..a6d1bf59 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -237,6 +237,7 @@ export const CLIPROXY_SUBCOMMANDS = [ 'edit', 'list', 'remove', + 'routing', 'catalog', 'sync', 'quota', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 0a516d73..523c08ad 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -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']); } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 641a2d2c..e3912a11 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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' }, diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 878dfbd1..95ec4fcd 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -373,6 +373,13 @@ function mergeWithDefaults(partial: Partial): 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, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index dd72d114..d594d984 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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', diff --git a/src/web-server/routes/cliproxy-routing-routes.ts b/src/web-server/routes/cliproxy-routing-routes.ts new file mode 100644 index 00000000..d1a21b54 --- /dev/null +++ b/src/web-server/routes/cliproxy-routing-routes.ts @@ -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 => { + 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 => { + 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; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 7e84b386..d1e9e5fe 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -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); diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts index 58edbdb2..12641a25 100644 --- a/tests/unit/cliproxy/management-api-client.test.ts +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -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; diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts new file mode 100644 index 00000000..9302cdac --- /dev/null +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -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) | 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); + }); +}); diff --git a/tests/unit/commands/cliproxy-routing-subcommand.test.ts b/tests/unit/commands/cliproxy-routing-subcommand.test.ts new file mode 100644 index 00000000..40d6385c --- /dev/null +++ b/tests/unit/commands/cliproxy-routing-subcommand.test.ts @@ -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']); + }); +}); diff --git a/tests/unit/web-server/cliproxy-routing-routes.test.ts b/tests/unit/web-server/cliproxy-routing-routes.test.ts new file mode 100644 index 00000000..ffaa71f6 --- /dev/null +++ b/tests/unit/web-server/cliproxy-routing-routes.test.ts @@ -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; + let applyStrategyMock: ReturnType; + + 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((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((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', + }); + }); +}); diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index 97fe4fa7..15918095 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -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 ( +
+
+
+
+ +
+
+
+

{title}

+

{subtitle}

+
+

{firstLine}

+

+ {secondLine} +

+
+
+ +
+ + {issueLabel} + + + {showProxySettingsLink ? ( + + + {proxySettingsLabel} + + ) : null} + + High Risk + +
+
+
+ ); + } + return (
-
+
-
+
-

{title}

+

{title}

{subtitle}

High Risk
-
-

{firstLine}

-

{secondLine}

-

- CCS is provided as-is and does not take responsibility for suspension, bans, or access - loss from upstream providers. -

-
+ {compact ? ( +
+

{firstLine}

+

+ {secondLine} +

+
+ ) : ( +
+

{firstLine}

+

{secondLine}

+

+ CCS is provided as-is and does not take responsibility for suspension, bans, or access + loss from upstream providers. +

+
+ )} diff --git a/ui/src/components/cliproxy/index.ts b/ui/src/components/cliproxy/index.ts index e6b08629..e13cb1b8 100644 --- a/ui/src/components/cliproxy/index.ts +++ b/ui/src/components/cliproxy/index.ts @@ -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'; diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index bee323a6..e602662d 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -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 ?
{topNotice}
: null} {isLoading ? (
diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index e470dcbf..ebb7dde4 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -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; diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx new file mode 100644 index 00000000..d3d330bf --- /dev/null +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -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 = { + '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(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 ( +
+
+
+
+
+ +
+
+
+ + Routing + + + {currentStrategy} + +
+

{statusLine}

+
+
+ + {!state?.reachable ? ( + + Saved default + + ) : null} +
+ +
+
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const active = selected === strategy; + return ( + + ); + })} +
+ +
+
+ Proxy-wide + {state?.source === 'config' ? ( + <> + + Local + + ) : null} +
+ +
+
+
+ ); + } + + return ( +
+
+
+
+
+ +
+
Routing strategy
+ {currentStrategy} + {state ? {sourceLabel} : null} + {state ? {state.target} : null} +
+

+ Proxy-wide account rotation. CCS keeps round-robin as the default until you explicitly + change it. +

+
+ +
+
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const active = selected === strategy; + return ( + + ); + })} +
+
+ + +
+
+ +
+ Round robin spreads usage. + + Fill first keeps backup accounts cold until they are needed. +
+ + {error ? ( +
+ {error.message} +
+ ) : null} + {!error && state?.message ? ( +
+ {state.message} +
+ ) : null} + + {detailsOpen ? ( +
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const current = currentStrategy === strategy; + return ( +
+
+
{copy.title}
+ {current ? Current : null} +
+

{copy.description}

+
+ ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 4d69d54b..484569fc 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -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')}

+ + updateRouting.mutate(strategy)} + />
); } @@ -450,6 +470,17 @@ export function ProxyStatusWidget() {
+ updateRouting.mutate(strategy)} + /> + {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index be6dc613..b6ee9e65 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -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(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7c9c5e8a..e5c83f6a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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('/cliproxy/routing/strategy'), + updateRoutingStrategy: (strategy: RoutingStrategy) => + request('/cliproxy/routing/strategy', { + method: 'PUT', + body: JSON.stringify({ value: strategy }), + }), aiProviders: { list: () => request('/cliproxy/ai-providers'), create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) => diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index ce4e2a05..39bc6124 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -443,11 +443,7 @@ export function CliproxyPage() {
{/* Right Panel */} -
- {showAccountSafetyWarning && ( - - )} - +
{selectedVariantData && parentAuthForVariant ? ( <> + ) : undefined + } onAddAccount={() => setAddAccountProvider({ provider: selectedVariantData.provider, @@ -507,6 +508,11 @@ export function CliproxyPage() { authStatus={selectedStatus} catalog={MODEL_CATALOGS[selectedStatus.provider]} isRemoteMode={isRemoteMode} + topNotice={ + showAccountSafetyWarning ? ( + + ) : undefined + } onAddAccount={() => setAddAccountProvider({ provider: selectedStatus.provider, diff --git a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx new file mode 100644 index 00000000..f22a8aae --- /dev/null +++ b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx @@ -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( + + ); + + 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( + undefined} + /> + ); + + expect(screen.getByText('Remote CLIProxy is not reachable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /use round-robin/i })).toBeDisabled(); + }); +}); From fabf3d4ad3cf1643837eded96c2a0b21503c7883 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 7 Apr 2026 18:22:05 -0400 Subject: [PATCH 2/3] fix(cliproxy): streamline sidebar routing control --- .../cliproxy/routing-guidance-card.tsx | 120 ++++++------------ 1 file changed, 40 insertions(+), 80 deletions(-) diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index d3d330bf..1fb1eac9 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ArrowRightLeft, ChevronDown, ChevronUp } from 'lucide-react'; +import { ArrowRightLeft, ChevronDown, ChevronUp, RefreshCw } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import type { CliproxyRoutingState, RoutingStrategy } from '@/lib/api-client'; @@ -43,88 +43,48 @@ export function RoutingGuidanceCard({ const detailToggleLabel = detailsOpen ? 'Hide details' : 'Show details'; if (compact) { - const statusLine = selected === 'fill-first' ? 'One account first' : 'Balanced usage'; + const handleApply = (s: RoutingStrategy) => { + setSelected(s); + if (s !== currentStrategy) { + onApply(s); + } + }; return ( -
-
-
-
-
- -
-
-
- - Routing - - - {currentStrategy} - -
-

{statusLine}

-
-
- - {!state?.reachable ? ( - - Saved default - - ) : null} -
- -
-
- {( - Object.entries(STRATEGY_COPY) as Array< - [RoutingStrategy, { title: string; description: string }] - > - ).map(([strategy, copy]) => { - const active = selected === strategy; - return ( - - ); - })} -
- -
-
- Proxy-wide - {state?.source === 'config' ? ( - <> - - Local - - ) : null} -
- -
+
+
+ + Routing + {isSaving && }
-
+ +
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const active = selected === strategy; + return ( + + ); + })} +
+
); } From 5bb5faa4b79c52f28875336edfc6b57306d36a7a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 7 Apr 2026 19:48:07 -0400 Subject: [PATCH 3/3] fix(cliproxy): tighten sidebar routing presentation --- .../account/account-safety-warning-card.tsx | 60 +++++-------------- .../cliproxy/routing-guidance-card.tsx | 36 ++++++++--- 2 files changed, 42 insertions(+), 54 deletions(-) diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index 15918095..b412caae 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -95,67 +95,45 @@ export function AccountSafetyWarningCard({ 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 )} >
-
+
-
+
-

{title}

+

{title}

{subtitle}

High Risk
- {compact ? ( -
-

{firstLine}

-

- {secondLine} -

-
- ) : ( -
-

{firstLine}

-

{secondLine}

-

- CCS is provided as-is and does not take responsibility for suspension, bans, or access - loss from upstream providers. -

-
- )} +
+

{firstLine}

+

{secondLine}

+

+ CCS is provided as-is and does not take responsibility for suspension, bans, or access + loss from upstream providers. +

+
diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index 1fb1eac9..73c85590 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -51,14 +51,32 @@ export function RoutingGuidanceCard({ }; return ( -
-
- - Routing - {isSaving && } +
+
+
+
+ +
+ + Routing + + {isSaving && }
-
+
+
{( Object.entries(STRATEGY_COPY) as Array< [RoutingStrategy, { title: string; description: string }] @@ -70,10 +88,10 @@ export function RoutingGuidanceCard({ key={strategy} type="button" className={cn( - 'relative z-10 rounded-[4px] px-2 py-0.5 text-[10px] font-medium transition-all duration-200', + 'relative z-10 flex items-center justify-center rounded px-2.5 py-0.5 text-[10px] font-medium whitespace-nowrap transition-colors duration-200', active - ? 'bg-background text-foreground shadow-[0_1px_2px_rgba(0,0,0,0.06)] ring-1 ring-black/5 dark:ring-white/10' - : 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5' + ? 'text-foreground group-hover/routing:text-primary' + : 'text-muted-foreground/70 hover:text-foreground/90 group-hover/routing:text-muted-foreground/90' )} onClick={() => handleApply(strategy)} disabled={isLoading || isSaving || !!error}