mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(cliproxy): add local session affinity controls
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-21
|
||||
Last Updated: 2026-04-28
|
||||
|
||||
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-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`.
|
||||
- **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers.
|
||||
- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves.
|
||||
- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in.
|
||||
|
||||
@@ -40,8 +40,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
* 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
|
||||
* v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 17;
|
||||
export const CLIPROXY_CONFIG_VERSION = 18;
|
||||
|
||||
interface RegenerateConfigOptions {
|
||||
configPath?: string;
|
||||
@@ -132,6 +133,15 @@ function getRoutingStrategy(): 'round-robin' | 'fill-first' {
|
||||
return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
|
||||
}
|
||||
|
||||
function getSessionAffinityEnabled(): boolean {
|
||||
return loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity ?? false;
|
||||
}
|
||||
|
||||
function getSessionAffinityTtl(): string {
|
||||
const ttl = loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity_ttl?.trim();
|
||||
return ttl || '1h';
|
||||
}
|
||||
|
||||
function sanitizeYamlScalar(rawValue: string): string {
|
||||
const trimmed = rawValue.trim();
|
||||
if (
|
||||
@@ -552,6 +562,8 @@ function generateUnifiedConfigContent(
|
||||
// Get logging settings from user config (disabled by default)
|
||||
const { loggingToFile, requestLog } = getLoggingSettings();
|
||||
const routingStrategy = getRoutingStrategy();
|
||||
const sessionAffinityEnabled = getSessionAffinityEnabled();
|
||||
const sessionAffinityTtl = getSessionAffinityTtl();
|
||||
|
||||
// Get effective auth tokens (respects user customization)
|
||||
const effectiveApiKey = getEffectiveApiKey();
|
||||
@@ -627,6 +639,8 @@ quota-exceeded:
|
||||
# Credential selection strategy when multiple matching accounts are available
|
||||
routing:
|
||||
strategy: ${routingStrategy}
|
||||
session-affinity: ${sessionAffinityEnabled}
|
||||
session-affinity-ttl: "${sessionAffinityTtl}"
|
||||
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import type { CliproxyRoutingStrategy } from './types';
|
||||
|
||||
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
|
||||
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
|
||||
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL = '1h';
|
||||
|
||||
const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`;
|
||||
const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
|
||||
|
||||
export interface CliproxyRoutingState {
|
||||
strategy: CliproxyRoutingStrategy;
|
||||
@@ -22,6 +27,25 @@ export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
|
||||
applied: 'live' | 'live-and-config' | 'config-only';
|
||||
}
|
||||
|
||||
export interface CliproxySessionAffinitySettings {
|
||||
enabled: boolean;
|
||||
ttl?: string;
|
||||
}
|
||||
|
||||
export interface CliproxySessionAffinityState {
|
||||
enabled?: boolean;
|
||||
ttl?: string;
|
||||
source: 'config' | 'unsupported';
|
||||
target: 'local' | 'remote';
|
||||
reachable: boolean;
|
||||
manageable: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffinityState {
|
||||
applied: 'config-only' | 'unsupported';
|
||||
}
|
||||
|
||||
export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutingStrategy | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
@@ -41,6 +65,47 @@ export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutin
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCliproxySessionAffinityEnabled(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case 'true':
|
||||
case '1':
|
||||
case 'yes':
|
||||
case 'on':
|
||||
case 'enable':
|
||||
case 'enabled':
|
||||
return true;
|
||||
case 'false':
|
||||
case '0':
|
||||
case 'no':
|
||||
case 'off':
|
||||
case 'disable':
|
||||
case 'disabled':
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCliproxySessionAffinityTtl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy {
|
||||
return (
|
||||
normalizeCliproxyRoutingStrategy(loadOrCreateUnifiedConfig().cliproxy?.routing?.strategy) ??
|
||||
@@ -48,6 +113,18 @@ export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredCliproxySessionAffinitySettings(): Required<CliproxySessionAffinitySettings> {
|
||||
const routing = loadOrCreateUnifiedConfig().cliproxy?.routing;
|
||||
return {
|
||||
enabled:
|
||||
normalizeCliproxySessionAffinityEnabled(routing?.session_affinity) ??
|
||||
DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED,
|
||||
ttl:
|
||||
normalizeCliproxySessionAffinityTtl(routing?.session_affinity_ttl) ??
|
||||
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLiveCliproxyRoutingStrategy(): Promise<CliproxyRoutingStrategy> {
|
||||
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET');
|
||||
if (!response.ok) {
|
||||
@@ -95,6 +172,36 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCliproxySessionAffinityState(): Promise<CliproxySessionAffinityState> {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
|
||||
if (target.isRemote) {
|
||||
return {
|
||||
source: 'unsupported',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
manageable: false,
|
||||
message:
|
||||
'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.',
|
||||
};
|
||||
}
|
||||
|
||||
const settings = getConfiguredCliproxySessionAffinitySettings();
|
||||
const reachable = await isLocalCliproxyReachable();
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
ttl: settings.ttl,
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable,
|
||||
manageable: true,
|
||||
message: reachable
|
||||
? 'CCS manages session affinity through the generated local CLIProxy config. Running local CLIProxy should hot-reload this setting.'
|
||||
: 'Local CLIProxy is not reachable. Showing the saved local startup default.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyCliproxyRoutingStrategy(
|
||||
strategy: CliproxyRoutingStrategy
|
||||
): Promise<CliproxyRoutingApplyResult> {
|
||||
@@ -116,7 +223,7 @@ export async function applyCliproxyRoutingStrategy(
|
||||
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = { strategy };
|
||||
config.cliproxy.routing = { ...config.cliproxy.routing, strategy };
|
||||
}
|
||||
});
|
||||
regenerateConfig(target.port, { configPath, authDir });
|
||||
@@ -143,6 +250,56 @@ export async function applyCliproxyRoutingStrategy(
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCliproxySessionAffinitySettings(
|
||||
settings: CliproxySessionAffinitySettings
|
||||
): Promise<CliproxySessionAffinityApplyResult> {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
if (target.isRemote) {
|
||||
return {
|
||||
source: 'unsupported',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
manageable: false,
|
||||
applied: 'unsupported',
|
||||
message:
|
||||
'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.',
|
||||
};
|
||||
}
|
||||
|
||||
const configPath = getConfigPathForPort(target.port);
|
||||
const authDir = getAuthDir();
|
||||
const current = getConfiguredCliproxySessionAffinitySettings();
|
||||
const ttl =
|
||||
normalizeCliproxySessionAffinityTtl(settings.ttl) ??
|
||||
current.ttl ??
|
||||
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL;
|
||||
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = {
|
||||
...config.cliproxy.routing,
|
||||
session_affinity: settings.enabled,
|
||||
session_affinity_ttl: ttl,
|
||||
};
|
||||
}
|
||||
});
|
||||
regenerateConfig(target.port, { configPath, authDir });
|
||||
|
||||
const reachable = await isLocalCliproxyReachable();
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
ttl,
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
message: reachable
|
||||
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
|
||||
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.',
|
||||
};
|
||||
}
|
||||
|
||||
async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise<void> {
|
||||
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', {
|
||||
value: strategy,
|
||||
@@ -156,3 +313,12 @@ async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function isLocalCliproxyReachable(): Promise<boolean> {
|
||||
try {
|
||||
await fetchLiveCliproxyRoutingStrategy();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ export interface CLIProxyConfig {
|
||||
debug: boolean;
|
||||
routing?: {
|
||||
strategy?: CliproxyRoutingStrategy;
|
||||
'session-affinity'?: boolean;
|
||||
'session-affinity-ttl'?: string;
|
||||
'claude-code-session-affinity'?: boolean;
|
||||
};
|
||||
'gemini-api-key'?: Array<{
|
||||
'api-key': string;
|
||||
|
||||
@@ -59,8 +59,10 @@ export async function showHelp(): Promise<void> {
|
||||
['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 explain', 'Explain strategy vs session-affinity and how sessions are recognized'],
|
||||
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
|
||||
['routing affinity', 'Show local session-affinity status and TTL'],
|
||||
['routing affinity <on|off> [--ttl <duration>]', 'Toggle local session-affinity settings'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -34,7 +34,13 @@ 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 {
|
||||
handleRoutingStatus,
|
||||
handleRoutingExplain,
|
||||
handleRoutingSet,
|
||||
handleRoutingAffinityStatus,
|
||||
handleRoutingAffinitySet,
|
||||
} from './routing-subcommand';
|
||||
import {
|
||||
handleCatalogStatus,
|
||||
handleCatalogRefresh,
|
||||
@@ -189,6 +195,14 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
await handleRoutingExplain();
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'affinity') {
|
||||
if (remainingArgs[2]) {
|
||||
await handleRoutingAffinitySet(remainingArgs.slice(2));
|
||||
return;
|
||||
}
|
||||
await handleRoutingAffinityStatus();
|
||||
return;
|
||||
}
|
||||
await handleRoutingStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui';
|
||||
import { extractOption } from '../arg-extractor';
|
||||
import {
|
||||
applyCliproxyRoutingStrategy,
|
||||
applyCliproxySessionAffinitySettings,
|
||||
normalizeCliproxyRoutingStrategy,
|
||||
normalizeCliproxySessionAffinityEnabled,
|
||||
normalizeCliproxySessionAffinityTtl,
|
||||
readCliproxyRoutingState,
|
||||
readCliproxySessionAffinityState,
|
||||
} from '../../cliproxy/routing-strategy';
|
||||
|
||||
function printStrategyGuide(): void {
|
||||
@@ -23,13 +28,52 @@ function printStrategyGuide(): void {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function printSessionAffinityGuide(): void {
|
||||
console.log(subheader('Session Affinity:'));
|
||||
console.log(
|
||||
` ${color('session-affinity off', 'command')} Each request follows the base routing strategy.`
|
||||
);
|
||||
console.log(` ${dim(' Best when you want pure proxy-wide balancing behavior.')}`);
|
||||
console.log('');
|
||||
console.log(
|
||||
` ${color('session-affinity on', 'command')} Keep one conversation pinned to the same account when possible.`
|
||||
);
|
||||
console.log(
|
||||
` ${dim(' Best when you want stronger prompt-cache locality for a single conversation.')}`
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function printSessionRecognitionGuide(): void {
|
||||
console.log(subheader('How CLIProxy Knows A Session Is New:'));
|
||||
console.log(
|
||||
` ${dim(' CLIProxy prefers explicit session or thread identifiers when clients send them.')}`
|
||||
);
|
||||
console.log(
|
||||
` ${dim(' Common examples: Claude session UUIDs, X-Session-ID, or provider-specific thread ids.')}`
|
||||
);
|
||||
console.log(
|
||||
` ${dim(' If no explicit identifier is present, it can fall back to fields such as metadata.user_id or conversation_id.')}`
|
||||
);
|
||||
console.log(
|
||||
` ${dim(' Last resort: it derives a stable key from the opening prompt history.')}`
|
||||
);
|
||||
console.log(
|
||||
` ${dim(' Exact precedence can vary by upstream backend/runtime version, so CCS does not promise one universal order.')}`
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleRoutingStatus(): Promise<void> {
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(header('CLIProxy Routing Strategy'));
|
||||
console.log('');
|
||||
|
||||
const state = await readCliproxyRoutingState();
|
||||
const [state, sessionAffinity] = await Promise.all([
|
||||
readCliproxyRoutingState(),
|
||||
readCliproxySessionAffinityState(),
|
||||
]);
|
||||
console.log(` Current: ${color(state.strategy, 'command')}`);
|
||||
console.log(` Target: ${color(state.target, 'info')}`);
|
||||
console.log(
|
||||
@@ -39,8 +83,28 @@ export async function handleRoutingStatus(): Promise<void> {
|
||||
console.log('');
|
||||
console.log(infoBox(state.message, state.reachable ? 'INFO' : 'WARNING'));
|
||||
}
|
||||
console.log(
|
||||
` Session Affinity: ${
|
||||
sessionAffinity.manageable
|
||||
? color(sessionAffinity.enabled ? 'on' : 'off', 'command')
|
||||
: color('unsupported', 'warning')
|
||||
}`
|
||||
);
|
||||
if (sessionAffinity.ttl) {
|
||||
console.log(` Affinity TTL: ${color(sessionAffinity.ttl, 'info')}`);
|
||||
}
|
||||
if (sessionAffinity.message) {
|
||||
console.log('');
|
||||
console.log(
|
||||
infoBox(
|
||||
sessionAffinity.message,
|
||||
sessionAffinity.manageable && sessionAffinity.reachable ? 'INFO' : 'WARNING'
|
||||
)
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
printStrategyGuide();
|
||||
printSessionAffinityGuide();
|
||||
}
|
||||
|
||||
export async function handleRoutingExplain(): Promise<void> {
|
||||
@@ -49,6 +113,8 @@ export async function handleRoutingExplain(): Promise<void> {
|
||||
console.log(header('CLIProxy Routing Guide'));
|
||||
console.log('');
|
||||
printStrategyGuide();
|
||||
printSessionAffinityGuide();
|
||||
printSessionRecognitionGuide();
|
||||
}
|
||||
|
||||
export async function handleRoutingSet(args: string[]): Promise<void> {
|
||||
@@ -78,3 +144,78 @@ export async function handleRoutingSet(args: string[]): Promise<void> {
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleRoutingAffinityStatus(): Promise<void> {
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(header('CLIProxy Session Affinity'));
|
||||
console.log('');
|
||||
|
||||
const state = await readCliproxySessionAffinityState();
|
||||
if (!state.manageable) {
|
||||
console.log(` Status: ${color('unsupported', 'warning')}`);
|
||||
} else {
|
||||
console.log(` Status: ${color(state.enabled ? 'on' : 'off', 'command')}`);
|
||||
}
|
||||
console.log(` Target: ${color(state.target, 'info')}`);
|
||||
if (state.ttl) {
|
||||
console.log(` TTL: ${color(state.ttl, 'info')}`);
|
||||
}
|
||||
if (state.message) {
|
||||
console.log('');
|
||||
console.log(infoBox(state.message, state.manageable && state.reachable ? 'INFO' : 'WARNING'));
|
||||
}
|
||||
console.log('');
|
||||
printSessionAffinityGuide();
|
||||
printSessionRecognitionGuide();
|
||||
}
|
||||
|
||||
export async function handleRoutingAffinitySet(args: string[]): Promise<void> {
|
||||
const requested = normalizeCliproxySessionAffinityEnabled(args[0]);
|
||||
const extractedTtl = extractOption(args.slice(1), ['--ttl']);
|
||||
const ttl: string | undefined =
|
||||
extractedTtl.found && !extractedTtl.missingValue
|
||||
? (normalizeCliproxySessionAffinityTtl(extractedTtl.value) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
if (requested === null || extractedTtl.missingValue || (extractedTtl.found && !ttl)) {
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(
|
||||
fail('Invalid session affinity command. Use: routing affinity <on|off> [--ttl 1h]')
|
||||
);
|
||||
console.log('');
|
||||
printSessionAffinityGuide();
|
||||
console.log(` ${dim('Accepted TTL examples: 30m, 1h, 2h30m')}`);
|
||||
console.log('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(header('Update CLIProxy Session Affinity'));
|
||||
console.log('');
|
||||
|
||||
const result = await applyCliproxySessionAffinitySettings({
|
||||
enabled: requested,
|
||||
ttl,
|
||||
});
|
||||
|
||||
if (!result.manageable) {
|
||||
console.log(fail(result.message || 'Session affinity is not supported for this target.'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(ok(`Session affinity ${requested ? 'enabled' : 'disabled'}`));
|
||||
if (result.ttl) {
|
||||
console.log(` TTL: ${color(result.ttl, 'info')}`);
|
||||
}
|
||||
console.log(` Applied: ${color(result.applied, 'info')}`);
|
||||
if (result.message) {
|
||||
console.log('');
|
||||
console.log(infoBox(result.message, result.reachable ? 'SUCCESS' : 'INFO'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -171,7 +171,10 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
if (lastToken === 'set') {
|
||||
return completeSubcommands(['round-robin', 'fill-first']);
|
||||
}
|
||||
return completeSubcommands(['set', 'explain']);
|
||||
if (lastToken === 'affinity') {
|
||||
return completeSubcommands(['on', 'off', '--ttl']);
|
||||
}
|
||||
return completeSubcommands(['set', 'explain', 'affinity']);
|
||||
}
|
||||
if (['remove', 'edit'].includes(subcommand)) {
|
||||
return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']);
|
||||
|
||||
@@ -440,6 +440,15 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
partial.cliproxy?.routing?.strategy === 'round-robin'
|
||||
? partial.cliproxy.routing.strategy
|
||||
: defaults.cliproxy.routing?.strategy,
|
||||
session_affinity:
|
||||
typeof partial.cliproxy?.routing?.session_affinity === 'boolean'
|
||||
? partial.cliproxy.routing.session_affinity
|
||||
: defaults.cliproxy.routing?.session_affinity,
|
||||
session_affinity_ttl:
|
||||
typeof partial.cliproxy?.routing?.session_affinity_ttl === 'string' &&
|
||||
partial.cliproxy.routing.session_affinity_ttl.trim()
|
||||
? partial.cliproxy.routing.session_affinity_ttl.trim()
|
||||
: defaults.cliproxy.routing?.session_affinity_ttl,
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
|
||||
@@ -205,6 +205,10 @@ export interface TokenRefreshSettings {
|
||||
export interface CLIProxyRoutingConfig {
|
||||
/** Credential selection strategy when multiple accounts match */
|
||||
strategy?: CliproxyRoutingStrategy;
|
||||
/** Keep one conversation pinned to the same account when possible */
|
||||
session_affinity?: boolean;
|
||||
/** Go-style duration for session-affinity binding retention */
|
||||
session_affinity_ttl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1034,6 +1038,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
auto_sync: true,
|
||||
routing: {
|
||||
strategy: 'round-robin',
|
||||
session_affinity: false,
|
||||
session_affinity_ttl: '1h',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
applyCliproxyRoutingStrategy,
|
||||
applyCliproxySessionAffinitySettings,
|
||||
normalizeCliproxyRoutingStrategy,
|
||||
normalizeCliproxySessionAffinityEnabled,
|
||||
normalizeCliproxySessionAffinityTtl,
|
||||
readCliproxyRoutingState,
|
||||
readCliproxySessionAffinityState,
|
||||
} from '../../cliproxy/routing-strategy';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
@@ -42,4 +46,37 @@ router.put('/routing/strategy', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/routing/session-affinity', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json(await readCliproxySessionAffinityState());
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/routing/session-affinity', async (req: Request, res: Response): Promise<void> => {
|
||||
const enabled = normalizeCliproxySessionAffinityEnabled(req.body?.enabled ?? req.body?.value);
|
||||
const ttl = req.body?.ttl;
|
||||
const normalizedTtl: string | undefined =
|
||||
ttl === undefined ? undefined : (normalizeCliproxySessionAffinityTtl(ttl) ?? undefined);
|
||||
|
||||
if (enabled === null || (ttl !== undefined && !normalizedTtl)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid session affinity payload. Use enabled=true|false and ttl like 30m or 1h.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(
|
||||
await applyCliproxySessionAffinitySettings({
|
||||
enabled,
|
||||
ttl: normalizedTtl,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -151,4 +151,111 @@ describe('cliproxy routing strategy service', () => {
|
||||
expect(methodCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes session-affinity booleans and TTL values', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled(true)).toBe(true);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('on')).toBe(true);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('false')).toBe(false);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('maybe')).toBeNull();
|
||||
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('1h')).toBe('1h');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('2h30m')).toBe('2h30m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl(' 15m ')).toBe('15m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('tomorrow')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('reads saved local session-affinity settings when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = {
|
||||
strategy: 'round-robin',
|
||||
session_affinity: true,
|
||||
session_affinity_ttl: '2h30m',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.enabled).toBe(true);
|
||||
expect(state.ttl).toBe('2h30m');
|
||||
expect(state.source).toBe('config');
|
||||
expect(state.target).toBe('local');
|
||||
expect(state.manageable).toBe(true);
|
||||
expect(state.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists local session-affinity settings even when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '2h',
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.ttl).toBe('2h');
|
||||
|
||||
const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const persisted = loadUnifiedConfig();
|
||||
expect(persisted?.cliproxy?.routing?.session_affinity).toBe(true);
|
||||
expect(persisted?.cliproxy?.routing?.session_affinity_ttl).toBe('2h');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not claim live session-affinity application just because local CLIProxy is reachable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '30m',
|
||||
});
|
||||
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.message).toContain('does not verify live selector state yet');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports remote session-affinity management as unsupported', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.target).toBe('remote');
|
||||
expect(state.manageable).toBe(false);
|
||||
expect(state.enabled).toBeUndefined();
|
||||
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('unsupported');
|
||||
expect(result.manageable).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ describe('cliproxy routing routes', () => {
|
||||
let baseUrl = '';
|
||||
let readStateMock: ReturnType<typeof mock>;
|
||||
let applyStrategyMock: ReturnType<typeof mock>;
|
||||
let readAffinityStateMock: ReturnType<typeof mock>;
|
||||
let applyAffinityMock: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(async () => {
|
||||
readStateMock = mock(async () => ({
|
||||
@@ -22,16 +24,43 @@ describe('cliproxy routing routes', () => {
|
||||
reachable: true,
|
||||
applied: 'live-and-config',
|
||||
}));
|
||||
readAffinityStateMock = mock(async () => ({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
}));
|
||||
applyAffinityMock = mock(async () => ({
|
||||
enabled: false,
|
||||
ttl: '30m',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/routing-strategy', () => ({
|
||||
readCliproxyRoutingState: readStateMock,
|
||||
applyCliproxyRoutingStrategy: applyStrategyMock,
|
||||
readCliproxySessionAffinityState: readAffinityStateMock,
|
||||
applyCliproxySessionAffinitySettings: applyAffinityMock,
|
||||
normalizeCliproxyRoutingStrategy: (value: unknown) => {
|
||||
if (value === 'round-robin' || value === 'fill-first') {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
normalizeCliproxySessionAffinityEnabled: (value: unknown) => {
|
||||
if (value === true || value === false) return value;
|
||||
return null;
|
||||
},
|
||||
normalizeCliproxySessionAffinityTtl: (value: unknown) => {
|
||||
if (value === '30m' || value === '1h') return value;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: routingRoutes } = await import(
|
||||
@@ -103,4 +132,52 @@ describe('cliproxy routing routes', () => {
|
||||
applied: 'live-and-config',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the current session-affinity state', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
});
|
||||
expect(readAffinityStateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid session-affinity payloads', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: 'auto', ttl: 'forever' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Invalid session affinity payload. Use enabled=true|false and ttl like 30m or 1h.',
|
||||
});
|
||||
expect(applyAffinityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies valid session-affinity settings', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/routing/session-affinity`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false, ttl: '30m' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(applyAffinityMock).toHaveBeenCalledWith({ enabled: false, ttl: '30m' });
|
||||
expect(await response.json()).toEqual({
|
||||
enabled: false,
|
||||
ttl: '30m',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from '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';
|
||||
import type {
|
||||
CliproxyRoutingState,
|
||||
RoutingStrategy,
|
||||
CliproxySessionAffinityState,
|
||||
} from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,10 +14,12 @@ interface RoutingGuidanceCardProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
state?: CliproxyRoutingState;
|
||||
sessionAffinityState?: CliproxySessionAffinityState;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error?: Error | null;
|
||||
onApply: (strategy: RoutingStrategy) => void;
|
||||
onApplyAffinity: (data: { enabled: boolean; ttl?: string }) => void;
|
||||
}
|
||||
|
||||
const STRATEGY_COPY: Record<RoutingStrategy, { title: string; description: string }> = {
|
||||
@@ -31,18 +37,77 @@ export function RoutingGuidanceCard({
|
||||
className,
|
||||
compact = false,
|
||||
state,
|
||||
sessionAffinityState,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
onApply,
|
||||
onApplyAffinity,
|
||||
}: RoutingGuidanceCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentStrategy = state?.strategy ?? 'round-robin';
|
||||
const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
|
||||
const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
|
||||
const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
|
||||
const [selected, setSelected] = useState<RoutingStrategy>(currentStrategy);
|
||||
const [selectedAffinityEnabled, setSelectedAffinityEnabled] = useState(currentAffinityEnabled);
|
||||
const [selectedAffinityTtl, setSelectedAffinityTtl] = useState(currentAffinityTtl);
|
||||
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';
|
||||
const affinityControlDisabled = isLoading || isSaving || !!error || !sessionAffinityManageable;
|
||||
const affinityActionLabel = sessionAffinityManageable
|
||||
? selectedAffinityEnabled
|
||||
? 'Disable session affinity'
|
||||
: 'Enable session affinity'
|
||||
: 'Session affinity unavailable';
|
||||
const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(currentStrategy);
|
||||
}, [currentStrategy]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAffinityEnabled(currentAffinityEnabled);
|
||||
setSelectedAffinityTtl(currentAffinityTtl);
|
||||
}, [currentAffinityEnabled, currentAffinityTtl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaving || !pendingAffinityRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingAffinityRef.current;
|
||||
const succeeded =
|
||||
pending.enabled === currentAffinityEnabled && pending.ttl === currentAffinityTtl;
|
||||
|
||||
if (!succeeded) {
|
||||
setSelectedAffinityEnabled(currentAffinityEnabled);
|
||||
setSelectedAffinityTtl(currentAffinityTtl);
|
||||
}
|
||||
|
||||
pendingAffinityRef.current = null;
|
||||
}, [isSaving, currentAffinityEnabled, currentAffinityTtl]);
|
||||
|
||||
const handleAffinityToggle = () => {
|
||||
if (!sessionAffinityManageable) return;
|
||||
const nextEnabled = !selectedAffinityEnabled;
|
||||
const nextTtl = selectedAffinityTtl.trim() || '1h';
|
||||
pendingAffinityRef.current = { enabled: nextEnabled, ttl: nextTtl };
|
||||
setSelectedAffinityEnabled(nextEnabled);
|
||||
onApplyAffinity({ enabled: nextEnabled, ttl: nextTtl });
|
||||
};
|
||||
|
||||
const handleAffinityTtlBlur = () => {
|
||||
if (!sessionAffinityManageable || !!error) return;
|
||||
const nextTtl = selectedAffinityTtl.trim() || '1h';
|
||||
if (nextTtl === currentAffinityTtl) {
|
||||
return;
|
||||
}
|
||||
pendingAffinityRef.current = { enabled: selectedAffinityEnabled, ttl: nextTtl };
|
||||
onApplyAffinity({ enabled: selectedAffinityEnabled, ttl: nextTtl });
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
const handleApply = (s: RoutingStrategy) => {
|
||||
@@ -53,57 +118,96 @@ export function RoutingGuidanceCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/routing flex items-center justify-between mt-1 p-1 -mx-1 rounded-lg transition-colors hover:bg-primary/5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-background border border-border/60 text-muted-foreground shadow-sm overflow-hidden transition-all duration-300 group-hover/routing:border-primary/40 group-hover/routing:text-primary group-hover/routing:shadow-[0_0_12px_rgba(59,130,246,0.15)] dark:group-hover/routing:shadow-[0_0_12px_rgba(59,130,246,0.1)]">
|
||||
<div className="absolute inset-0 bg-primary/10 translate-y-full group-hover/routing:translate-y-0 transition-transform duration-300 ease-out" />
|
||||
<ArrowRightLeft className="relative z-10 h-3.5 w-3.5 transition-transform duration-300 group-hover/routing:scale-110" />
|
||||
<div className={cn('group/routing mt-1 space-y-2 -mx-1 rounded-lg p-1', className)}>
|
||||
<div className="flex items-center justify-between rounded-lg transition-colors hover:bg-primary/5">
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium text-foreground">
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-background border border-border/60 text-muted-foreground shadow-sm overflow-hidden transition-all duration-300 group-hover/routing:border-primary/40 group-hover/routing:text-primary group-hover/routing:shadow-[0_0_12px_rgba(59,130,246,0.15)] dark:group-hover/routing:shadow-[0_0_12px_rgba(59,130,246,0.1)]">
|
||||
<div className="absolute inset-0 bg-primary/10 translate-y-full group-hover/routing:translate-y-0 transition-transform duration-300 ease-out" />
|
||||
<ArrowRightLeft className="relative z-10 h-3.5 w-3.5 transition-transform duration-300 group-hover/routing:scale-110" />
|
||||
</div>
|
||||
<span className="tracking-tight transition-colors duration-300 group-hover/routing:text-primary group-hover/routing:font-semibold">
|
||||
Routing
|
||||
</span>
|
||||
{isSaving && <RefreshCw className="ml-1 h-3 w-3 shrink-0 animate-spin text-primary" />}
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 p-0.5 gap-0.5 rounded-lg border border-border/60 bg-muted/30 shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)] dark:shadow-[inset_0_1px_3px_rgba(0,0,0,0.2)] transition-colors duration-300 group-hover/routing:border-primary/20 group-hover/routing:bg-primary/5">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0.5 left-0.5 w-[calc(50%-0.1875rem)] rounded bg-background shadow-[0_1px_3px_rgba(0,0,0,0.1),0_1px_2px_rgba(0,0,0,0.06)] ring-1 ring-black/5 dark:ring-white/10 transition-all duration-300',
|
||||
selected === 'fill-first' ? 'translate-x-[calc(100%+0.125rem)]' : 'translate-x-0',
|
||||
'group-hover/routing:shadow-[0_0_8px_rgba(59,130,246,0.15)] dark:group-hover/routing:shadow-[0_0_8px_rgba(59,130,246,0.1)] group-hover/routing:ring-primary/30'
|
||||
)}
|
||||
style={{ transitionTimingFunction: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)' }}
|
||||
/>
|
||||
{(
|
||||
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 flex items-center justify-center rounded px-2.5 py-0.5 text-[10px] font-medium whitespace-nowrap transition-colors duration-200',
|
||||
active
|
||||
? '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}
|
||||
title={copy.description}
|
||||
>
|
||||
{copy.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="tracking-tight transition-colors duration-300 group-hover/routing:text-primary group-hover/routing:font-semibold">
|
||||
Routing
|
||||
</span>
|
||||
{isSaving && <RefreshCw className="ml-1 h-3 w-3 shrink-0 animate-spin text-primary" />}
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 p-0.5 gap-0.5 rounded-lg border border-border/60 bg-muted/30 shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)] dark:shadow-[inset_0_1px_3px_rgba(0,0,0,0.2)] transition-colors duration-300 group-hover/routing:border-primary/20 group-hover/routing:bg-primary/5">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0.5 left-0.5 w-[calc(50%-0.1875rem)] rounded bg-background shadow-[0_1px_3px_rgba(0,0,0,0.1),0_1px_2px_rgba(0,0,0,0.06)] ring-1 ring-black/5 dark:ring-white/10 transition-all duration-300',
|
||||
selected === 'fill-first' ? 'translate-x-[calc(100%+0.125rem)]' : 'translate-x-0',
|
||||
'group-hover/routing:shadow-[0_0_8px_rgba(59,130,246,0.15)] dark:group-hover/routing:shadow-[0_0_8px_rgba(59,130,246,0.1)] group-hover/routing:ring-primary/30'
|
||||
)}
|
||||
style={{ transitionTimingFunction: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)' }}
|
||||
/>
|
||||
{(
|
||||
Object.entries(STRATEGY_COPY) as Array<
|
||||
[RoutingStrategy, { title: string; description: string }]
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground">Session affinity</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{sessionAffinityManageable ? `TTL ${currentAffinityTtl}` : 'Local-only setting'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{sessionAffinityManageable ? (
|
||||
<input
|
||||
aria-label="Session affinity TTL"
|
||||
className="h-6 w-14 rounded border border-border/70 bg-background px-2 text-[10px] text-foreground"
|
||||
value={selectedAffinityTtl}
|
||||
onChange={(event) => setSelectedAffinityTtl(event.target.value)}
|
||||
onBlur={handleAffinityTtlBlur}
|
||||
disabled={affinityControlDisabled}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={affinityActionLabel}
|
||||
className={cn(
|
||||
'rounded border px-2 py-1 text-[10px] font-medium transition-colors',
|
||||
sessionAffinityManageable
|
||||
? 'border-border/70 bg-background text-foreground hover:border-primary/40 hover:text-primary'
|
||||
: 'border-border/60 bg-muted/40 text-muted-foreground'
|
||||
)}
|
||||
onClick={handleAffinityToggle}
|
||||
disabled={affinityControlDisabled}
|
||||
title={sessionAffinityState?.message}
|
||||
>
|
||||
).map(([strategy, copy]) => {
|
||||
const active = selected === strategy;
|
||||
return (
|
||||
<button
|
||||
key={strategy}
|
||||
type="button"
|
||||
className={cn(
|
||||
'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
|
||||
? '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}
|
||||
title={copy.description}
|
||||
>
|
||||
{copy.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sessionAffinityManageable ? (selectedAffinityEnabled ? 'On' : 'Off') : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionAffinityState?.message ? (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5 text-[10px] text-muted-foreground">
|
||||
{sessionAffinityState.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +285,50 @@ export function RoutingGuidanceCard({
|
||||
<span>{t('routingGuidance.fillFirst')}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 xl:col-span-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium">Session affinity</div>
|
||||
<Badge variant="secondary">{selectedAffinityEnabled ? 'on' : 'off'}</Badge>
|
||||
{sessionAffinityState?.ttl ? (
|
||||
<Badge variant="outline">TTL {currentAffinityTtl}</Badge>
|
||||
) : null}
|
||||
{!sessionAffinityManageable ? <Badge variant="outline">Local only</Badge> : null}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Keep one conversation pinned to the same account when possible. CLIProxy prefers
|
||||
explicit session or thread identifiers when clients send them, then falls back to
|
||||
request metadata or the opening prompt history when it has to infer a stable key.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Session affinity TTL"
|
||||
className="h-9 w-24 rounded-md border border-border/70 bg-background px-3 text-sm text-foreground"
|
||||
value={selectedAffinityTtl}
|
||||
onChange={(event) => setSelectedAffinityTtl(event.target.value)}
|
||||
onBlur={handleAffinityTtlBlur}
|
||||
disabled={affinityControlDisabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAffinityToggle}
|
||||
disabled={affinityControlDisabled}
|
||||
aria-label={affinityActionLabel}
|
||||
>
|
||||
{sessionAffinityManageable
|
||||
? selectedAffinityEnabled
|
||||
? 'Disable session affinity'
|
||||
: 'Enable session affinity'
|
||||
: 'Session affinity unavailable'}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionAffinityState?.message ? (
|
||||
<div className="rounded-lg border border-border/70 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
{sessionAffinityState.message}
|
||||
</div>
|
||||
) : null}
|
||||
</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}
|
||||
@@ -210,6 +358,14 @@ export function RoutingGuidanceCard({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className="text-sm font-medium">Session recognition order</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
CCS does not promise one universal precedence order here. In practice, upstream
|
||||
backends prefer explicit session or thread ids first, then fall back to metadata
|
||||
fields and finally a hash based on the opening prompt history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
useInstallVersion,
|
||||
useRestartProxy,
|
||||
useCliproxyRoutingStrategy,
|
||||
useCliproxySessionAffinity,
|
||||
useUpdateCliproxyRoutingStrategy,
|
||||
useUpdateCliproxySessionAffinity,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -159,6 +161,19 @@ export function ProxyStatusWidget() {
|
||||
error: routingError,
|
||||
} = useCliproxyRoutingStrategy();
|
||||
const updateRouting = useUpdateCliproxyRoutingStrategy();
|
||||
const {
|
||||
data: sessionAffinityState,
|
||||
isLoading: sessionAffinityLoading,
|
||||
error: sessionAffinityError,
|
||||
} = useCliproxySessionAffinity();
|
||||
const updateSessionAffinity = useUpdateCliproxySessionAffinity();
|
||||
const isSavingRoutingConfig = updateRouting.isPending || updateSessionAffinity.isPending;
|
||||
const routingConfigError =
|
||||
routingError instanceof Error
|
||||
? routingError
|
||||
: sessionAffinityError instanceof Error
|
||||
? sessionAffinityError
|
||||
: null;
|
||||
const startProxy = useStartProxy();
|
||||
const stopProxy = useStopProxy();
|
||||
const restartProxy = useRestartProxy();
|
||||
@@ -315,14 +330,16 @@ export function ProxyStatusWidget() {
|
||||
</div>
|
||||
|
||||
<RoutingGuidanceCard
|
||||
key={`remote:${routingState?.strategy ?? 'round-robin'}`}
|
||||
key={`remote:${routingState?.strategy ?? 'round-robin'}:${sessionAffinityState?.enabled ?? 'na'}:${sessionAffinityState?.ttl ?? 'na'}:${sessionAffinityState?.manageable ?? 'na'}`}
|
||||
compact
|
||||
className="mt-3"
|
||||
state={routingState}
|
||||
isLoading={routingLoading}
|
||||
isSaving={updateRouting.isPending}
|
||||
error={routingError instanceof Error ? routingError : null}
|
||||
sessionAffinityState={sessionAffinityState}
|
||||
isLoading={routingLoading || sessionAffinityLoading}
|
||||
isSaving={isSavingRoutingConfig}
|
||||
error={routingConfigError}
|
||||
onApply={(strategy) => updateRouting.mutate(strategy)}
|
||||
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -471,14 +488,16 @@ export function ProxyStatusWidget() {
|
||||
</div>
|
||||
|
||||
<RoutingGuidanceCard
|
||||
key={`local:${routingState?.strategy ?? 'round-robin'}`}
|
||||
key={`local:${routingState?.strategy ?? 'round-robin'}:${sessionAffinityState?.enabled ?? 'na'}:${sessionAffinityState?.ttl ?? 'na'}:${sessionAffinityState?.manageable ?? 'na'}`}
|
||||
compact
|
||||
className="mt-3"
|
||||
state={routingState}
|
||||
isLoading={routingLoading}
|
||||
isSaving={updateRouting.isPending}
|
||||
error={routingError instanceof Error ? routingError : null}
|
||||
sessionAffinityState={sessionAffinityState}
|
||||
isLoading={routingLoading || sessionAffinityLoading}
|
||||
isSaving={isSavingRoutingConfig}
|
||||
error={routingConfigError}
|
||||
onApply={(strategy) => updateRouting.mutate(strategy)}
|
||||
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
|
||||
/>
|
||||
|
||||
{/* Expanded section: Version Management (available even when not running) */}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type UpdateVariant,
|
||||
type CreatePreset,
|
||||
type RoutingStrategy,
|
||||
type CliproxySessionAffinityApplyResult,
|
||||
} from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next';
|
||||
function invalidateCliproxyRoutingQueries(queryClient: ReturnType<typeof useQueryClient>): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] });
|
||||
}
|
||||
|
||||
function invalidateCliproxyAccountQueries(queryClient: ReturnType<typeof useQueryClient>): void {
|
||||
@@ -74,6 +76,30 @@ export function useUpdateCliproxyRoutingStrategy() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCliproxySessionAffinity() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-session-affinity'],
|
||||
queryFn: () => api.cliproxy.getSessionAffinity(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCliproxySessionAffinity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { enabled: boolean; ttl?: string }) =>
|
||||
api.cliproxy.updateSessionAffinity(data),
|
||||
onSuccess: (result: CliproxySessionAffinityApplyResult) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] });
|
||||
const label = result.enabled ? 'enabled' : 'disabled';
|
||||
toast.success(result.message || `Session affinity ${label}.`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -521,6 +521,20 @@ export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
|
||||
applied: 'live' | 'live-and-config' | 'config-only';
|
||||
}
|
||||
|
||||
export interface CliproxySessionAffinityState {
|
||||
enabled?: boolean;
|
||||
ttl?: string;
|
||||
source: 'config' | 'unsupported';
|
||||
target: 'local' | 'remote';
|
||||
reachable: boolean;
|
||||
manageable: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffinityState {
|
||||
applied: 'config-and-live' | 'config-only' | 'unsupported';
|
||||
}
|
||||
|
||||
/** Auth file info for Config tab */
|
||||
export interface AuthFile {
|
||||
name: string;
|
||||
@@ -1253,6 +1267,13 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: strategy }),
|
||||
}),
|
||||
getSessionAffinity: () =>
|
||||
request<CliproxySessionAffinityState>('/cliproxy/routing/session-affinity'),
|
||||
updateSessionAffinity: (data: { enabled: boolean; ttl?: string }) =>
|
||||
request<CliproxySessionAffinityApplyResult>('/cliproxy/routing/session-affinity', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
aiProviders: {
|
||||
list: () => request<ListAiProvidersResult>('/cliproxy/ai-providers'),
|
||||
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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();
|
||||
const onApplyAffinity = vi.fn();
|
||||
|
||||
render(
|
||||
<RoutingGuidanceCard
|
||||
@@ -14,19 +15,33 @@ describe('RoutingGuidanceCard', () => {
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
}}
|
||||
sessionAffinityState={{
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
onApply={onApply}
|
||||
onApplyAffinity={onApplyAffinity}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Routing strategy')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('round-robin').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Session affinity')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('1h')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /fill first/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /use fill-first/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('fill-first');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /disable session affinity/i }));
|
||||
expect(onApplyAffinity).toHaveBeenCalledWith({ enabled: false, ttl: '1h' });
|
||||
});
|
||||
|
||||
it('shows the error state and disables apply', () => {
|
||||
@@ -36,10 +51,40 @@ describe('RoutingGuidanceCard', () => {
|
||||
isSaving={false}
|
||||
error={new Error('Remote CLIProxy is not reachable')}
|
||||
onApply={() => undefined}
|
||||
onApplyAffinity={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Remote CLIProxy is not reachable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /use round-robin/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows remote session-affinity guidance when the setting is not manageable', () => {
|
||||
render(
|
||||
<RoutingGuidanceCard
|
||||
state={{
|
||||
strategy: 'round-robin',
|
||||
source: 'live',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
}}
|
||||
sessionAffinityState={{
|
||||
source: 'unsupported',
|
||||
target: 'remote',
|
||||
reachable: true,
|
||||
manageable: false,
|
||||
message: 'Remote session-affinity management is not supported from CCS yet.',
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
onApply={() => undefined}
|
||||
onApplyAffinity={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Remote session-affinity management is not supported from CCS yet.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user