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(); + }); });