From 4309db89c33d7021e6ba1ceb6045a95082c47805 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 28 Apr 2026 12:17:26 -0400 Subject: [PATCH 1/5] feat(cliproxy): add local session affinity controls --- docs/project-roadmap.md | 3 +- src/cliproxy/config/generator.ts | 16 +- src/cliproxy/routing-strategy.ts | 168 +++++++++++- src/cliproxy/types.ts | 3 + src/commands/cliproxy/help-subcommand.ts | 4 +- src/commands/cliproxy/index.ts | 16 +- src/commands/cliproxy/routing-subcommand.ts | 143 +++++++++- src/commands/completion-backend.ts | 5 +- src/config/unified-config-loader.ts | 9 + src/config/unified-config-types.ts | 6 + .../routes/cliproxy-routing-routes.ts | 37 +++ tests/unit/cliproxy/routing-strategy.test.ts | 107 ++++++++ .../cliproxy-routing-routes.test.ts | 77 ++++++ .../cliproxy/routing-guidance-card.tsx | 252 ++++++++++++++---- .../monitoring/proxy-status-widget.tsx | 35 ++- ui/src/hooks/use-cliproxy.ts | 26 ++ ui/src/lib/api-client.ts | 21 ++ .../cliproxy/routing-guidance-card.test.tsx | 45 ++++ 18 files changed, 910 insertions(+), 63 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index a07dcae8..f439fae7 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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. diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 43ce4672..926bdc9e 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -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 diff --git a/src/cliproxy/routing-strategy.ts b/src/cliproxy/routing-strategy.ts index ff621a9b..038e7dea 100644 --- a/src/cliproxy/routing-strategy.ts +++ b/src/cliproxy/routing-strategy.ts @@ -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 { + 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 { const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET'); if (!response.ok) { @@ -95,6 +172,36 @@ export async function readCliproxyRoutingState(): Promise } } +export async function readCliproxySessionAffinityState(): Promise { + 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 { @@ -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 { + 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 { const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', { value: strategy, @@ -156,3 +313,12 @@ async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrate ); } } + +async function isLocalCliproxyReachable(): Promise { + try { + await fetchLiveCliproxyRoutingStrategy(); + return true; + } catch { + return false; + } +} diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index abf00136..2abd2052 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -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; diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index ccecad99..f349be7e 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -59,8 +59,10 @@ export async function showHelp(): Promise { ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ['routing', 'Show current routing strategy and manual guidance'], - ['routing explain', 'Explain round-robin vs fill-first'], + ['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'], ['routing set ', 'Explicitly set round-robin or fill-first'], + ['routing affinity', 'Show local session-affinity status and TTL'], + ['routing affinity [--ttl ]', 'Toggle local session-affinity settings'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b7de5ebb..b269371b 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -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 { await handleRoutingExplain(); return; } + if (subcommand === 'affinity') { + if (remainingArgs[2]) { + await handleRoutingAffinitySet(remainingArgs.slice(2)); + return; + } + await handleRoutingAffinityStatus(); + return; + } await handleRoutingStatus(); return; } diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index f78aed21..326a9249 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -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 { 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 { 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 { @@ -49,6 +113,8 @@ export async function handleRoutingExplain(): Promise { console.log(header('CLIProxy Routing Guide')); console.log(''); printStrategyGuide(); + printSessionAffinityGuide(); + printSessionRecognitionGuide(); } export async function handleRoutingSet(args: string[]): Promise { @@ -78,3 +144,78 @@ export async function handleRoutingSet(args: string[]): Promise { } console.log(''); } + +export async function handleRoutingAffinityStatus(): Promise { + 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 { + 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 [--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(''); +} diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index c968c720..a18ff712 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -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']); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 261b2d72..12e348c8 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -440,6 +440,15 @@ function mergeWithDefaults(partial: Partial): 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: { diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 4639744c..ab4eca6c 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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: { diff --git a/src/web-server/routes/cliproxy-routing-routes.ts b/src/web-server/routes/cliproxy-routing-routes.ts index d1a21b54..93c63ac4 100644 --- a/src/web-server/routes/cliproxy-routing-routes.ts +++ b/src/web-server/routes/cliproxy-routing-routes.ts @@ -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 => { + 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 => { + 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; diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index 3a195e14..9d222d7e 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -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); + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-routing-routes.test.ts b/tests/unit/web-server/cliproxy-routing-routes.test.ts index ffaa71f6..a3dd9bd6 100644 --- a/tests/unit/web-server/cliproxy-routing-routes.test.ts +++ b/tests/unit/web-server/cliproxy-routing-routes.test.ts @@ -7,6 +7,8 @@ describe('cliproxy routing routes', () => { let baseUrl = ''; let readStateMock: ReturnType; let applyStrategyMock: ReturnType; + let readAffinityStateMock: ReturnType; + let applyAffinityMock: ReturnType; 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', + }); + }); }); diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index bffc9230..94cbe112 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -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 = { @@ -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(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 ( -
-
-
-
- +
+
+
+
+
+ +
+ + Routing + + {isSaving && } +
+ +
+
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const active = selected === strategy; + return ( + + ); + })}
- - Routing - - {isSaving && }
-
-
- {( - Object.entries(STRATEGY_COPY) as Array< - [RoutingStrategy, { title: string; description: string }] +
+
+
Session affinity
+
+ {sessionAffinityManageable ? `TTL ${currentAffinityTtl}` : 'Local-only setting'} +
+
+
+ {sessionAffinityManageable ? ( + setSelectedAffinityTtl(event.target.value)} + onBlur={handleAffinityTtlBlur} + disabled={affinityControlDisabled} + /> + ) : null} + - ); - })} + {sessionAffinityManageable ? (selectedAffinityEnabled ? 'On' : 'Off') : 'Unavailable'} + +
+ + {sessionAffinityState?.message ? ( +
+ {sessionAffinityState.message} +
+ ) : null}
); } @@ -181,6 +285,50 @@ export function RoutingGuidanceCard({ {t('routingGuidance.fillFirst')}
+
+
+
Session affinity
+ {selectedAffinityEnabled ? 'on' : 'off'} + {sessionAffinityState?.ttl ? ( + TTL {currentAffinityTtl} + ) : null} + {!sessionAffinityManageable ? Local only : null} +
+

+ 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. +

+
+ setSelectedAffinityTtl(event.target.value)} + onBlur={handleAffinityTtlBlur} + disabled={affinityControlDisabled} + /> + +
+ {sessionAffinityState?.message ? ( +
+ {sessionAffinityState.message} +
+ ) : null} +
+ {error ? (
{error.message} @@ -210,6 +358,14 @@ export function RoutingGuidanceCard({
); })} +
+
Session recognition order
+

+ 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. +

+
) : null}
diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 484569fc..3ad7809b 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -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() {
updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} />
); @@ -471,14 +488,16 @@ export function ProxyStatusWidget() {
updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} /> {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index ff1e9354..c7a58dd0 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -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): void { queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] }); } function invalidateCliproxyAccountQueries(queryClient: ReturnType): 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(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e48e4dc4..566ce094 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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('/cliproxy/routing/session-affinity'), + updateSessionAffinity: (data: { enabled: boolean; ttl?: string }) => + request('/cliproxy/routing/session-affinity', { + method: 'PUT', + body: JSON.stringify(data), + }), aiProviders: { list: () => request('/cliproxy/ai-providers'), create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) => diff --git a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx index f22a8aae..6fc4e1f3 100644 --- a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx +++ b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx @@ -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( { 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( + 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(); + }); }); From 20f3916d55046e44de58982a7905ca3ad4e8fc7d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 28 Apr 2026 12:26:42 -0400 Subject: [PATCH 2/5] fix(cliproxy): tighten session affinity status handling --- src/cliproxy/config/generator.ts | 4 ++- .../monitoring/proxy-status-widget.tsx | 28 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 926bdc9e..c10432e8 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -99,6 +99,8 @@ const MIN_STALE_HIGH_ONLY_GEMINI_MINOR_VERSIONS = 3; const MIN_STALE_GUESSED_GEMINI_AVERAGE_VARIANTS_PER_MINOR = 2; const LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION = 16; const MAX_LEGACY_MANUAL_GEMINI_MINOR_VERSION = 2; +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}+$`); /** * Get provider configuration @@ -139,7 +141,7 @@ function getSessionAffinityEnabled(): boolean { function getSessionAffinityTtl(): string { const ttl = loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity_ttl?.trim(); - return ttl || '1h'; + return ttl && GO_DURATION_PATTERN.test(ttl) ? ttl : '1h'; } function sanitizeYamlScalar(rawValue: string): string { diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 3ad7809b..0c6e1ffa 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -168,12 +168,20 @@ export function ProxyStatusWidget() { } = useCliproxySessionAffinity(); const updateSessionAffinity = useUpdateCliproxySessionAffinity(); const isSavingRoutingConfig = updateRouting.isPending || updateSessionAffinity.isPending; - const routingConfigError = - routingError instanceof Error - ? routingError - : sessionAffinityError instanceof Error - ? sessionAffinityError - : null; + const routingConfigError = routingError instanceof Error ? routingError : null; + const effectiveSessionAffinityState = + sessionAffinityState ?? + (sessionAffinityError instanceof Error + ? { + source: 'unsupported' as const, + target: (routingState?.target ?? (isRemoteMode ? 'remote' : 'local')) as + | 'local' + | 'remote', + reachable: false, + manageable: false, + message: sessionAffinityError.message, + } + : undefined); const startProxy = useStartProxy(); const stopProxy = useStopProxy(); const restartProxy = useRestartProxy(); @@ -330,11 +338,11 @@ export function ProxyStatusWidget() {
Date: Tue, 28 Apr 2026 12:33:12 -0400 Subject: [PATCH 3/5] fix(cliproxy): tighten session affinity review issues --- src/commands/cliproxy/routing-subcommand.ts | 8 ++++- src/commands/completion-backend.ts | 18 ++++++++-- src/config/unified-config-loader.ts | 24 ++++++++++--- .../unit/commands/completion-backend.test.ts | 16 +++++++++ tests/unit/unified-config.test.ts | 35 +++++++++++++++++++ ui/src/hooks/use-cliproxy.ts | 1 + 6 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index 326a9249..c2272597 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -173,12 +173,18 @@ export async function handleRoutingAffinityStatus(): Promise { export async function handleRoutingAffinitySet(args: string[]): Promise { const requested = normalizeCliproxySessionAffinityEnabled(args[0]); const extractedTtl = extractOption(args.slice(1), ['--ttl']); + const remainingArgs = extractedTtl.remainingArgs.filter((token) => token.trim().length > 0); const ttl: string | undefined = extractedTtl.found && !extractedTtl.missingValue ? (normalizeCliproxySessionAffinityTtl(extractedTtl.value) ?? undefined) : undefined; - if (requested === null || extractedTtl.missingValue || (extractedTtl.found && !ttl)) { + if ( + requested === null || + extractedTtl.missingValue || + (extractedTtl.found && !ttl) || + remainingArgs.length > 0 + ) { await initUI(); console.log(''); console.log( diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index a18ff712..660503af 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -168,11 +168,25 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg '-h', ]); if (subcommand === 'routing') { + const routingSubcommand = tokensBeforeCurrent[2]; + const routingAffinityMode = tokensBeforeCurrent[3]; if (lastToken === 'set') { return completeSubcommands(['round-robin', 'fill-first']); } - if (lastToken === 'affinity') { - return completeSubcommands(['on', 'off', '--ttl']); + if (routingSubcommand === 'affinity') { + if (!routingAffinityMode || lastToken === 'affinity') { + return completeSubcommands(['on', 'off']); + } + if ( + (routingAffinityMode === 'on' || routingAffinityMode === 'off') && + !tokensBeforeCurrent.includes('--ttl') + ) { + return completeSubcommands([], ['--ttl']); + } + if (lastToken === '--ttl') { + return []; + } + return completeSubcommands([]); } return completeSubcommands(['set', 'explain', 'affinity']); } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 12e348c8..9c4dc5b7 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -59,6 +59,8 @@ const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; const CONFIG_LOCK = 'config.yaml.lock'; const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds +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}+$`); function normalizeBrowserDevtoolsPort(value: number | undefined): number { if (!Number.isFinite(value)) { @@ -112,6 +114,19 @@ function canonicalizeBrowserConfig( }; } +function normalizeSessionAffinityTtl(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + + const trimmed = value.trim(); + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) { + return fallback; + } + + return trimmed; +} + /** * Get path to unified config.yaml */ @@ -444,11 +459,10 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { 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, + session_affinity_ttl: normalizeSessionAffinityTtl( + partial.cliproxy?.routing?.session_affinity_ttl, + defaults.cliproxy.routing?.session_affinity_ttl ?? '1h' + ), }, }, proxy: { diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index b683043e..4667dc76 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -128,6 +128,22 @@ describe('completion backend', () => { expect(values).toContain('my-codex'); }); + test('suggests the correct routing affinity completion shape', () => { + expect(suggestionValues(['cliproxy', 'routing'])).toEqual( + expect.arrayContaining(['set', 'explain', 'affinity']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity'])).toEqual( + expect.arrayContaining(['on', 'off']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'on'])).toEqual( + expect.arrayContaining(['--ttl']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', 'off'])).toEqual( + expect.arrayContaining(['--ttl']) + ); + expect(suggestionValues(['cliproxy', 'routing', 'affinity', '--ttl'])).not.toContain('--ttl'); + }); + test('suggests env format values after the format flag', () => { const values = suggestionValues(['env', '--format']); expect(values).toEqual( diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 1fc660c5..8113dae5 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -285,6 +285,41 @@ describe('continuity-inheritance-config', () => { }); }); +describe('cliproxy session-affinity ttl normalization', () => { + it('normalizes invalid stored ttl values back to the safe default', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-ttl-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'cliproxy:', + ' routing:', + ' strategy: round-robin', + ' session_affinity: true', + ' session_affinity_ttl: forever', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); + describe('official-channels-config', () => { it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => { const originalCcsHome = process.env.CCS_HOME; diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index c7a58dd0..f8e12a5c 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -90,6 +90,7 @@ export function useUpdateCliproxySessionAffinity() { mutationFn: (data: { enabled: boolean; ttl?: string }) => api.cliproxy.updateSessionAffinity(data), onSuccess: (result: CliproxySessionAffinityApplyResult) => { + queryClient.setQueryData(['cliproxy-session-affinity'], result); queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] }); const label = result.enabled ? 'enabled' : 'disabled'; toast.success(result.message || `Session affinity ${label}.`); From 148cf3556045823a3a3d5a34f7745d420d93d825 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 28 Apr 2026 12:38:30 -0400 Subject: [PATCH 4/5] fix(cliproxy): polish session affinity controls --- src/commands/cliproxy/index.ts | 5 ++++ src/commands/cliproxy/routing-subcommand.ts | 17 ++++++++++++ .../monitoring/proxy-status-widget.tsx | 26 +++++++++---------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b269371b..02ae8bc5 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -39,6 +39,7 @@ import { handleRoutingExplain, handleRoutingSet, handleRoutingAffinityStatus, + handleRoutingAffinityHelp, handleRoutingAffinitySet, } from './routing-subcommand'; import { @@ -196,6 +197,10 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } if (subcommand === 'affinity') { + if (hasAnyFlag(remainingArgs.slice(2), ['--help', '-h'])) { + await handleRoutingAffinityHelp(); + return; + } if (remainingArgs[2]) { await handleRoutingAffinitySet(remainingArgs.slice(2)); return; diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index c2272597..ea63d9f2 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -170,6 +170,23 @@ export async function handleRoutingAffinityStatus(): Promise { printSessionRecognitionGuide(); } +export async function handleRoutingAffinityHelp(): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Session Affinity')); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs cliproxy routing affinity', 'command')}`); + console.log(` ${color('ccs cliproxy routing affinity on', 'command')}`); + console.log(` ${color('ccs cliproxy routing affinity off', 'command')}`); + console.log(` ${color('ccs cliproxy routing affinity on --ttl 1h', 'command')}`); + console.log(''); + printSessionAffinityGuide(); + console.log(` ${dim('Accepted TTL examples: 30m, 1h, 2h30m')}`); + console.log(''); + printSessionRecognitionGuide(); +} + export async function handleRoutingAffinitySet(args: string[]): Promise { const requested = normalizeCliproxySessionAffinityEnabled(args[0]); const extractedTtl = extractOption(args.slice(1), ['--ttl']); diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 0c6e1ffa..cfa026df 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -169,19 +169,6 @@ export function ProxyStatusWidget() { const updateSessionAffinity = useUpdateCliproxySessionAffinity(); const isSavingRoutingConfig = updateRouting.isPending || updateSessionAffinity.isPending; const routingConfigError = routingError instanceof Error ? routingError : null; - const effectiveSessionAffinityState = - sessionAffinityState ?? - (sessionAffinityError instanceof Error - ? { - source: 'unsupported' as const, - target: (routingState?.target ?? (isRemoteMode ? 'remote' : 'local')) as - | 'local' - | 'remote', - reachable: false, - manageable: false, - message: sessionAffinityError.message, - } - : undefined); const startProxy = useStartProxy(); const stopProxy = useStopProxy(); const restartProxy = useRestartProxy(); @@ -213,6 +200,19 @@ export function ProxyStatusWidget() { // Determine if remote mode is enabled const remoteConfig = cliproxyConfig?.remote; const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host; + const effectiveSessionAffinityState = + sessionAffinityState ?? + (sessionAffinityError instanceof Error + ? { + source: 'unsupported' as const, + target: (routingState?.target ?? (isRemoteMode ? 'remote' : 'local')) as + | 'local' + | 'remote', + reachable: false, + manageable: false, + message: sessionAffinityError.message, + } + : undefined); const isRunning = status?.running ?? false; const isActioning = From 5267c421ca4dca2d1f92312e569e93bd22d9c1a1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 28 Apr 2026 13:27:05 -0400 Subject: [PATCH 5/5] fix(cliproxy): harden session affinity behavior --- src/cliproxy/config/generator.ts | 14 +++- src/cliproxy/routing-strategy.ts | 38 ++++++++--- src/commands/cliproxy/routing-subcommand.ts | 4 ++ src/config/unified-config-loader.ts | 14 +++- .../routes/cliproxy-routing-routes.ts | 17 +++-- tests/unit/cliproxy/routing-strategy.test.ts | 27 ++++++++ tests/unit/unified-config.test.ts | 33 +++++++++ .../cliproxy/routing-guidance-card.tsx | 65 ++++++++++++------ ui/src/hooks/use-cliproxy.ts | 9 ++- ui/src/lib/i18n.ts | 68 +++++++++++++++++++ 10 files changed, 246 insertions(+), 43 deletions(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index c10432e8..3b4f7803 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -141,7 +141,19 @@ function getSessionAffinityEnabled(): boolean { function getSessionAffinityTtl(): string { const ttl = loadOrCreateUnifiedConfig().cliproxy?.routing?.session_affinity_ttl?.trim(); - return ttl && GO_DURATION_PATTERN.test(ttl) ? ttl : '1h'; + return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h'; +} + +function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); } function sanitizeYamlScalar(rawValue: string): string { diff --git a/src/cliproxy/routing-strategy.ts b/src/cliproxy/routing-strategy.ts index 038e7dea..5cc97735 100644 --- a/src/cliproxy/routing-strategy.ts +++ b/src/cliproxy/routing-strategy.ts @@ -99,7 +99,7 @@ export function normalizeCliproxySessionAffinityTtl(value: unknown): string | nu } const trimmed = value.trim(); - if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) { + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) { return null; } @@ -176,18 +176,20 @@ export async function readCliproxySessionAffinityState(): Promise { const target = getCliproxyRoutingTarget(); if (target.isRemote) { + const reachable = await isLiveCliproxyRoutingReachable(); return { source: 'unsupported', target: 'remote', - reachable: true, + reachable, manageable: false, applied: 'unsupported', - message: - 'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.', + message: reachable + ? 'Remote session-affinity management is not supported from CCS yet because upstream management APIs only expose routing.strategy.' + : 'Remote session-affinity management is not supported from CCS yet, and the remote CLIProxy routing endpoint is not reachable.', }; } @@ -285,7 +289,7 @@ export async function applyCliproxySessionAffinitySettings( }); regenerateConfig(target.port, { configPath, authDir }); - const reachable = await isLocalCliproxyReachable(); + const reachable = await isLiveCliproxyRoutingReachable(); return { enabled: settings.enabled, ttl, @@ -314,7 +318,19 @@ async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrate } } -async function isLocalCliproxyReachable(): Promise { +function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); +} + +async function isLiveCliproxyRoutingReachable(): Promise { try { await fetchLiveCliproxyRoutingStrategy(); return true; diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts index ea63d9f2..45fcfd14 100644 --- a/src/commands/cliproxy/routing-subcommand.ts +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -157,6 +157,9 @@ export async function handleRoutingAffinityStatus(): Promise { } else { console.log(` Status: ${color(state.enabled ? 'on' : 'off', 'command')}`); } + console.log( + ` Source: ${state.manageable ? color('saved local setting', 'info') : color('unsupported', 'warning')}` + ); console.log(` Target: ${color(state.target, 'info')}`); if (state.ttl) { console.log(` TTL: ${color(state.ttl, 'info')}`); @@ -228,6 +231,7 @@ export async function handleRoutingAffinitySet(args: string[]): Promise { if (!result.manageable) { console.log(fail(result.message || 'Session affinity is not supported for this target.')); console.log(''); + process.exitCode = 1; return; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 9c4dc5b7..16c2efb2 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -120,13 +120,25 @@ function normalizeSessionAffinityTtl(value: unknown, fallback: string): string { } const trimmed = value.trim(); - if (!trimmed || !GO_DURATION_PATTERN.test(trimmed)) { + if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) { return fallback; } return trimmed; } +function hasPositiveDuration(value: string): boolean { + const segments = value.match(new RegExp(GO_DURATION_SEGMENT, 'g')); + if (!segments) { + return false; + } + + return segments.some((segment) => { + const numeric = parseFloat(segment); + return Number.isFinite(numeric) && numeric > 0; + }); +} + /** * Get path to unified config.yaml */ diff --git a/src/web-server/routes/cliproxy-routing-routes.ts b/src/web-server/routes/cliproxy-routing-routes.ts index 93c63ac4..0fa1c987 100644 --- a/src/web-server/routes/cliproxy-routing-routes.ts +++ b/src/web-server/routes/cliproxy-routing-routes.ts @@ -68,12 +68,17 @@ router.put('/routing/session-affinity', async (req: Request, res: Response): Pro } try { - res.json( - await applyCliproxySessionAffinitySettings({ - enabled, - ttl: normalizedTtl, - }) - ); + const result = await applyCliproxySessionAffinitySettings({ + enabled, + ttl: normalizedTtl, + }); + if (!result.manageable || result.applied === 'unsupported') { + res.status(400).json({ + error: result.message || 'Session affinity is not supported for this target.', + }); + return; + } + res.json(result); } catch (error) { res.status(502).json({ error: (error as Error).message }); } diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index 9d222d7e..73f959f4 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -164,6 +164,7 @@ describe('cliproxy routing strategy service', () => { expect(mod.normalizeCliproxySessionAffinityTtl('1h')).toBe('1h'); expect(mod.normalizeCliproxySessionAffinityTtl('2h30m')).toBe('2h30m'); expect(mod.normalizeCliproxySessionAffinityTtl(' 15m ')).toBe('15m'); + expect(mod.normalizeCliproxySessionAffinityTtl('0s')).toBeNull(); expect(mod.normalizeCliproxySessionAffinityTtl('tomorrow')).toBeNull(); }); }); @@ -241,12 +242,19 @@ describe('cliproxy routing strategy service', () => { isRemote: true, }; + responseFactory = async () => + new Response(JSON.stringify({ strategy: 'round-robin' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + 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.reachable).toBe(true); expect(state.enabled).toBeUndefined(); const result = await mod.applyCliproxySessionAffinitySettings({ @@ -258,4 +266,23 @@ describe('cliproxy routing strategy service', () => { expect(result.manageable).toBe(false); }); }); + + it('reports unsupported remote session-affinity as unreachable when remote routing probe fails', async () => { + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; + responseFactory = null; + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxySessionAffinityState(); + + expect(state.source).toBe('unsupported'); + expect(state.reachable).toBe(false); + expect(state.message).toContain('not reachable'); + }); + }); }); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index 8113dae5..b8566a28 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -318,6 +318,39 @@ describe('cliproxy session-affinity ttl normalization', () => { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + + it('normalizes non-positive stored ttl values back to the safe default', () => { + const originalCcsHome = process.env.CCS_HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-affinity-zero-ttl-home-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'cliproxy:', + ' routing:', + ' strategy: round-robin', + ' session_affinity: true', + ' session_affinity_ttl: 0s', + '', + ].join('\n') + ); + + process.env.CCS_HOME = tempHome; + try { + const config = loadOrCreateUnifiedConfig(); + expect(config.cliproxy.routing.session_affinity_ttl).toBe('1h'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); }); describe('official-channels-config', () => { diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index 94cbe112..ac7eaf5f 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -59,10 +59,11 @@ export function RoutingGuidanceCard({ const affinityControlDisabled = isLoading || isSaving || !!error || !sessionAffinityManageable; const affinityActionLabel = sessionAffinityManageable ? selectedAffinityEnabled - ? 'Disable session affinity' - : 'Enable session affinity' - : 'Session affinity unavailable'; + ? t('routingGuidance.disableSessionAffinity') + : t('routingGuidance.enableSessionAffinity') + : t('routingGuidance.sessionAffinityUnavailable'); const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null); + const suppressNextAffinityBlurRef = useRef(false); useEffect(() => { setSelected(currentStrategy); @@ -101,6 +102,10 @@ export function RoutingGuidanceCard({ const handleAffinityTtlBlur = () => { if (!sessionAffinityManageable || !!error) return; + if (suppressNextAffinityBlurRef.current) { + suppressNextAffinityBlurRef.current = false; + return; + } const nextTtl = selectedAffinityTtl.trim() || '1h'; if (nextTtl === currentAffinityTtl) { return; @@ -169,9 +174,13 @@ export function RoutingGuidanceCard({
-
Session affinity
+
+ {t('routingGuidance.sessionAffinity')} +
- {sessionAffinityManageable ? `TTL ${currentAffinityTtl}` : 'Local-only setting'} + {sessionAffinityManageable + ? t('routingGuidance.ttlBadge', { ttl: currentAffinityTtl }) + : t('routingGuidance.localOnlySetting')}
@@ -194,11 +203,18 @@ export function RoutingGuidanceCard({ ? 'border-border/70 bg-background text-foreground hover:border-primary/40 hover:text-primary' : 'border-border/60 bg-muted/40 text-muted-foreground' )} + onMouseDown={() => { + suppressNextAffinityBlurRef.current = true; + }} onClick={handleAffinityToggle} disabled={affinityControlDisabled} title={sessionAffinityState?.message} > - {sessionAffinityManageable ? (selectedAffinityEnabled ? 'On' : 'Off') : 'Unavailable'} + {sessionAffinityManageable + ? selectedAffinityEnabled + ? t('routingGuidance.sessionAffinityOn') + : t('routingGuidance.sessionAffinityOff') + : t('routingGuidance.sessionAffinityUnavailable')}
@@ -287,17 +303,23 @@ export function RoutingGuidanceCard({
-
Session affinity
- {selectedAffinityEnabled ? 'on' : 'off'} +
{t('routingGuidance.sessionAffinity')}
+ + {selectedAffinityEnabled + ? t('routingGuidance.sessionAffinityOn') + : t('routingGuidance.sessionAffinityOff')} + {sessionAffinityState?.ttl ? ( - TTL {currentAffinityTtl} + + {t('routingGuidance.ttlBadge', { ttl: currentAffinityTtl })} + + ) : null} + {!sessionAffinityManageable ? ( + {t('routingGuidance.localOnly')} ) : null} - {!sessionAffinityManageable ? Local only : null}

- 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. + {t('routingGuidance.sessionAffinityDescription')}

{ + suppressNextAffinityBlurRef.current = true; + }} onClick={handleAffinityToggle} disabled={affinityControlDisabled} aria-label={affinityActionLabel} > - {sessionAffinityManageable - ? selectedAffinityEnabled - ? 'Disable session affinity' - : 'Enable session affinity' - : 'Session affinity unavailable'} + {affinityActionLabel}
{sessionAffinityState?.message ? ( @@ -359,11 +380,11 @@ export function RoutingGuidanceCard({ ); })}
-
Session recognition order
+
+ {t('routingGuidance.sessionRecognitionTitle')} +

- 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. + {t('routingGuidance.sessionRecognitionDescription')}

diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index f8e12a5c..98c23f55 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -85,6 +85,7 @@ export function useCliproxySessionAffinity() { export function useUpdateCliproxySessionAffinity() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data: { enabled: boolean; ttl?: string }) => @@ -92,8 +93,12 @@ export function useUpdateCliproxySessionAffinity() { onSuccess: (result: CliproxySessionAffinityApplyResult) => { queryClient.setQueryData(['cliproxy-session-affinity'], result); queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] }); - const label = result.enabled ? 'enabled' : 'disabled'; - toast.success(result.message || `Session affinity ${label}.`); + const stateLabel = result.enabled + ? t('routingGuidance.sessionAffinityEnabled') + : t('routingGuidance.sessionAffinityDisabled'); + toast.success( + result.message || t('toasts.sessionAffinityUpdated', { state: stateLabel.toLowerCase() }) + ); }, onError: (error: Error) => { toast.error(error.message); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 6c42ace6..b452e78a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1820,6 +1820,22 @@ const resources = { fillFirst: 'Fill first keeps backup accounts cold until they are needed.', routingStrategy: 'Routing strategy', optionalRouting: 'Optional routing', + sessionAffinity: 'Session affinity', + sessionAffinityOn: 'On', + sessionAffinityOff: 'Off', + sessionAffinityEnabled: 'Enabled', + sessionAffinityDisabled: 'Disabled', + enableSessionAffinity: 'Enable session affinity', + disableSessionAffinity: 'Disable session affinity', + sessionAffinityUnavailable: 'Session affinity unavailable', + localOnly: 'Local only', + localOnlySetting: 'Local-only setting', + ttlBadge: 'TTL {{ttl}}', + sessionAffinityDescription: + '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.', + sessionRecognitionTitle: 'Session recognition', + sessionRecognitionDescription: + '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.', }, extendedContext: { extendedContext: 'Extended Context', @@ -2170,6 +2186,7 @@ const resources = { accountsUpdated: 'Accounts updated', noProfilesToSync: 'No profiles to sync', syncFailed: 'Sync failed: {{error}}', + sessionAffinityUpdated: 'Session affinity {{state}}.', providerAuthSuccess: '{{provider}} authentication successful', providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode', providerAuthTimeout: 'Authentication timed out. Please try again.', @@ -4283,6 +4300,22 @@ const resources = { fillFirst: '优先填满模式让备用账号保持冷启动直到需要时。', routingStrategy: '路由策略', optionalRouting: '可选路由', + sessionAffinity: '会话粘性', + sessionAffinityOn: '开启', + sessionAffinityOff: '关闭', + sessionAffinityEnabled: '已启用', + sessionAffinityDisabled: '已禁用', + enableSessionAffinity: '启用会话粘性', + disableSessionAffinity: '禁用会话粘性', + sessionAffinityUnavailable: '会话粘性不可用', + localOnly: '仅本地', + localOnlySetting: '仅限本地设置', + ttlBadge: 'TTL {{ttl}}', + sessionAffinityDescription: + '尽量将同一对话固定到同一个账号。CLIProxy 会优先使用客户端显式提供的会话或线程标识;如果没有,再回退到请求元数据或开场提示历史来推断稳定键。', + sessionRecognitionTitle: '会话识别', + sessionRecognitionDescription: + 'CCS 不承诺所有后端都使用同一优先级顺序。通常上游会优先使用显式会话或线程 ID,然后回退到元数据字段,最后再回退到基于开场提示历史的哈希。', }, extendedContext: { extendedContext: '扩展上下文', @@ -4618,6 +4651,7 @@ const resources = { accountsUpdated: '账号已更新', noProfilesToSync: '没有可同步的配置', syncFailed: '同步失败:{{error}}', + sessionAffinityUpdated: '会话粘性已{{state}}。', providerAuthSuccess: '{{provider}} 认证成功', providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程', providerAuthTimeout: '认证超时,请重试。', @@ -6816,6 +6850,22 @@ const resources = { fillFirst: 'Fill-first giữ tài khoản dự phòng cho đến khi cần thiết.', routingStrategy: 'Chiến lược định tuyến', optionalRouting: 'Định tuyến tùy chọn', + sessionAffinity: 'Ghim phiên', + sessionAffinityOn: 'Bật', + sessionAffinityOff: 'Tắt', + sessionAffinityEnabled: 'bật', + sessionAffinityDisabled: 'tắt', + enableSessionAffinity: 'Bật ghim phiên', + disableSessionAffinity: 'Tắt ghim phiên', + sessionAffinityUnavailable: 'Ghim phiên không khả dụng', + localOnly: 'Chỉ cục bộ', + localOnlySetting: 'Thiết lập chỉ cục bộ', + ttlBadge: 'TTL {{ttl}}', + sessionAffinityDescription: + 'Giữ một cuộc hội thoại trên cùng một tài khoản khi có thể. CLIProxy ưu tiên mã phiên hoặc luồng mà client gửi rõ ràng; nếu không có, nó sẽ dùng metadata của request hoặc lịch sử prompt mở đầu để suy ra khóa ổn định.', + sessionRecognitionTitle: 'Nhận diện phiên', + sessionRecognitionDescription: + 'CCS không cam kết một thứ tự ưu tiên chung cho mọi backend. Trên thực tế, backend upstream thường ưu tiên session hoặc thread id tường minh, rồi mới fallback sang metadata và cuối cùng là hàm băm của lịch sử prompt mở đầu.', }, extendedContext: { extendedContext: 'Ngữ cảnh mở rộng', @@ -7154,6 +7204,7 @@ const resources = { accountsUpdated: 'Tài khoản đã được cập nhật', noProfilesToSync: 'Không có hồ sơ để đồng bộ', syncFailed: 'Đồng bộ thất bại: {{error}}', + sessionAffinityUpdated: 'Ghim phiên đã {{state}}.', providerAuthSuccess: 'Xác thực {{provider}} thành công', providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback', providerAuthTimeout: 'Đã hết thời gian xác thực. Vui lòng thử lại.', @@ -9778,6 +9829,22 @@ const resources = { fillFirst: 'Fill first は、バックアップアカウントが必要になるまで待機させます。', routingStrategy: 'ルーティング戦略', optionalRouting: 'オプションのルーティング', + sessionAffinity: 'セッション固定', + sessionAffinityOn: 'オン', + sessionAffinityOff: 'オフ', + sessionAffinityEnabled: '有効', + sessionAffinityDisabled: '無効', + enableSessionAffinity: 'セッション固定を有効化', + disableSessionAffinity: 'セッション固定を無効化', + sessionAffinityUnavailable: 'セッション固定は利用できません', + localOnly: 'ローカルのみ', + localOnlySetting: 'ローカル専用設定', + ttlBadge: 'TTL {{ttl}}', + sessionAffinityDescription: + '可能な場合は 1 つの会話を同じアカウントに固定します。CLIProxy はクライアントが明示的に送るセッション ID やスレッド ID を優先し、それがない場合はリクエストのメタデータや冒頭プロンプト履歴から安定キーを推定します。', + sessionRecognitionTitle: 'セッション認識', + sessionRecognitionDescription: + 'CCS はすべてのバックエンドで同一の優先順位を保証しません。一般に上流バックエンドは明示的なセッション / スレッド ID を優先し、その後にメタデータ、最後に冒頭プロンプト履歴のハッシュへフォールバックします。', }, settingsDialog: { editProfile: 'プロファイルを編集: {{name}}', @@ -10003,6 +10070,7 @@ const resources = { accountsUpdated: 'アカウントを更新しました', noProfilesToSync: '同期するプロファイルがありません', syncFailed: '同期に失敗しました: {{error}}', + sessionAffinityUpdated: 'セッション固定を{{state}}にしました。', providerAuthSuccess: '{{provider}} の認証に成功しました', providerDeviceCodeInCallback: 'コールバックモードでプロバイダーがデバイスコードフローを返しました',