feat(cliproxy): add local session affinity controls

This commit is contained in:
Tam Nhu Tran
2026-04-28 12:17:26 -04:00
parent 0867378644
commit 4309db89c3
18 changed files with 910 additions and 63 deletions
+2 -1
View File
@@ -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.
+15 -1
View File
@@ -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
+167 -1
View File
@@ -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;
}
}
+3
View File
@@ -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;
+3 -1
View File
@@ -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'],
],
],
[
+15 -1
View File
@@ -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;
}
+142 -1
View File
@@ -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('');
}
+4 -1
View File
@@ -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']);
+9
View File
@@ -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: {
+6
View File
@@ -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) */}
+26
View File
@@ -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();
+21
View File
@@ -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();
});
});