mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
feat(cliproxy): add routing guidance and strategy controls
This commit is contained in:
@@ -82,7 +82,9 @@ config. Deep dive:
|
||||
|
||||

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