Merge pull request #1117 from kaitranntt/kai/feat/1115-session-affinity

feat: add local CLIProxy session affinity controls
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-28 13:37:37 -04:00
commit a8e0d95bc8
21 files changed
+1231 -63

No files matched your search

+29 -1
View File
@@ -40,8 +40,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v15: Prune stale generated Antigravity Gemini preview aliases during regeneration
* v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges
* v17: Persist routing.strategy from CCS unified config
* v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config
*/
export const CLIPROXY_CONFIG_VERSION = 17;
export const CLIPROXY_CONFIG_VERSION = 18;
interface RegenerateConfigOptions {
configPath?: string;
@@ -98,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
@@ -132,6 +135,27 @@ 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 && 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 {
const trimmed = rawValue.trim();
if (
@@ -552,6 +576,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 +653,8 @@ quota-exceeded:
# Credential selection strategy when multiple matching accounts are available
routing:
strategy: ${routingStrategy}
session-affinity: ${sessionAffinityEnabled}
session-affinity-ttl: "${sessionAffinityTtl}"
# =============================================================================
# Authentication
+183 -1
View File
@@ -9,6 +9,11 @@ import {
import type { CliproxyRoutingStrategy } from './types';
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL = '1h';
const GO_DURATION_SEGMENT = String.raw`(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))`;
const GO_DURATION_PATTERN = new RegExp(`^${GO_DURATION_SEGMENT}+$`);
export interface CliproxyRoutingState {
strategy: CliproxyRoutingStrategy;
@@ -22,6 +27,25 @@ export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
applied: 'live' | 'live-and-config' | 'config-only';
}
export interface CliproxySessionAffinitySettings {
enabled: boolean;
ttl?: string;
}
export interface CliproxySessionAffinityState {
enabled?: boolean;
ttl?: string;
source: 'config' | 'unsupported';
target: 'local' | 'remote';
reachable: boolean;
manageable: boolean;
message?: string;
}
export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffinityState {
applied: 'config-only' | 'unsupported';
}
export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutingStrategy | null {
if (typeof value !== 'string') {
return null;
@@ -41,6 +65,47 @@ export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutin
}
}
export function normalizeCliproxySessionAffinityEnabled(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value;
}
if (typeof value !== 'string') {
return null;
}
switch (value.trim().toLowerCase()) {
case 'true':
case '1':
case 'yes':
case 'on':
case 'enable':
case 'enabled':
return true;
case 'false':
case '0':
case 'no':
case 'off':
case 'disable':
case 'disabled':
return false;
default:
return null;
}
}
export function normalizeCliproxySessionAffinityTtl(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed || !GO_DURATION_PATTERN.test(trimmed) || !hasPositiveDuration(trimmed)) {
return null;
}
return trimmed;
}
export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy {
return (
normalizeCliproxyRoutingStrategy(loadOrCreateUnifiedConfig().cliproxy?.routing?.strategy) ??
@@ -48,6 +113,18 @@ export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy
);
}
export function getConfiguredCliproxySessionAffinitySettings(): Required<CliproxySessionAffinitySettings> {
const routing = loadOrCreateUnifiedConfig().cliproxy?.routing;
return {
enabled:
normalizeCliproxySessionAffinityEnabled(routing?.session_affinity) ??
DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED,
ttl:
normalizeCliproxySessionAffinityTtl(routing?.session_affinity_ttl) ??
DEFAULT_CLIPROXY_SESSION_AFFINITY_TTL,
};
}
export async function fetchLiveCliproxyRoutingStrategy(): Promise<CliproxyRoutingStrategy> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET');
if (!response.ok) {
@@ -95,6 +172,38 @@ export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState>
}
}
export async function readCliproxySessionAffinityState(): Promise<CliproxySessionAffinityState> {
const target = getCliproxyRoutingTarget();
if (target.isRemote) {
const reachable = await isLiveCliproxyRoutingReachable();
return {
source: 'unsupported',
target: 'remote',
reachable,
manageable: false,
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.',
};
}
const settings = getConfiguredCliproxySessionAffinitySettings();
const reachable = await isLiveCliproxyRoutingReachable();
return {
enabled: settings.enabled,
ttl: settings.ttl,
source: 'config',
target: 'local',
reachable,
manageable: true,
message: reachable
? 'Showing the saved local session-affinity setting. Running local CLIProxy may hot-reload config changes, but CCS does not verify live selector state.'
: 'Local CLIProxy is not reachable. Showing the saved local startup default.',
};
}
export async function applyCliproxyRoutingStrategy(
strategy: CliproxyRoutingStrategy
): Promise<CliproxyRoutingApplyResult> {
@@ -116,7 +225,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 +252,58 @@ export async function applyCliproxyRoutingStrategy(
}
}
export async function applyCliproxySessionAffinitySettings(
settings: CliproxySessionAffinitySettings
): Promise<CliproxySessionAffinityApplyResult> {
const target = getCliproxyRoutingTarget();
if (target.isRemote) {
const reachable = await isLiveCliproxyRoutingReachable();
return {
source: 'unsupported',
target: 'remote',
reachable,
manageable: false,
applied: 'unsupported',
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.',
};
}
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 isLiveCliproxyRoutingReachable();
return {
enabled: settings.enabled,
ttl,
source: 'config',
target: 'local',
reachable,
manageable: true,
applied: 'config-only',
message: reachable
? 'Saved the local startup default. Running local CLIProxy may hot-reload the session-affinity setting, but CCS does not verify live selector state yet.'
: 'Saved the local startup default. It will apply the next time local CLIProxy starts.',
};
}
async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise<void> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', {
value: strategy,
@@ -156,3 +317,24 @@ async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrate
);
}
}
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<boolean> {
try {
await fetchLiveCliproxyRoutingStrategy();
return true;
} catch {
return false;
}
}
+3
View File
@@ -194,6 +194,9 @@ export interface CLIProxyConfig {
debug: boolean;
routing?: {
strategy?: CliproxyRoutingStrategy;
'session-affinity'?: boolean;
'session-affinity-ttl'?: string;
'claude-code-session-affinity'?: boolean;
};
'gemini-api-key'?: Array<{
'api-key': string;
+3 -1
View File
@@ -59,8 +59,10 @@ export async function showHelp(): Promise<void> {
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
['routing', 'Show current routing strategy and manual guidance'],
['routing explain', 'Explain round-robin vs fill-first'],
['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'],
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
['routing affinity', 'Show local session-affinity status and TTL'],
['routing affinity <on|off> [--ttl <duration>]', 'Toggle local session-affinity settings'],
],
],
[
+20 -1
View File
@@ -34,7 +34,14 @@ 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,
handleRoutingAffinityHelp,
handleRoutingAffinitySet,
} from './routing-subcommand';
import {
handleCatalogStatus,
handleCatalogRefresh,
@@ -189,6 +196,18 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
await handleRoutingExplain();
return;
}
if (subcommand === 'affinity') {
if (hasAnyFlag(remainingArgs.slice(2), ['--help', '-h'])) {
await handleRoutingAffinityHelp();
return;
}
if (remainingArgs[2]) {
await handleRoutingAffinitySet(remainingArgs.slice(2));
return;
}
await handleRoutingAffinityStatus();
return;
}
await handleRoutingStatus();
return;
}
+169 -1
View File
@@ -1,8 +1,13 @@
import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui';
import { extractOption } from '../arg-extractor';
import {
applyCliproxyRoutingStrategy,
applyCliproxySessionAffinitySettings,
normalizeCliproxyRoutingStrategy,
normalizeCliproxySessionAffinityEnabled,
normalizeCliproxySessionAffinityTtl,
readCliproxyRoutingState,
readCliproxySessionAffinityState,
} from '../../cliproxy/routing-strategy';
function printStrategyGuide(): void {
@@ -23,13 +28,52 @@ function printStrategyGuide(): void {
console.log('');
}
function printSessionAffinityGuide(): void {
console.log(subheader('Session Affinity:'));
console.log(
` ${color('session-affinity off', 'command')} Each request follows the base routing strategy.`
);
console.log(` ${dim(' Best when you want pure proxy-wide balancing behavior.')}`);
console.log('');
console.log(
` ${color('session-affinity on', 'command')} Keep one conversation pinned to the same account when possible.`
);
console.log(
` ${dim(' Best when you want stronger prompt-cache locality for a single conversation.')}`
);
console.log('');
}
function printSessionRecognitionGuide(): void {
console.log(subheader('How CLIProxy Knows A Session Is New:'));
console.log(
` ${dim(' CLIProxy prefers explicit session or thread identifiers when clients send them.')}`
);
console.log(
` ${dim(' Common examples: Claude session UUIDs, X-Session-ID, or provider-specific thread ids.')}`
);
console.log(
` ${dim(' If no explicit identifier is present, it can fall back to fields such as metadata.user_id or conversation_id.')}`
);
console.log(
` ${dim(' Last resort: it derives a stable key from the opening prompt history.')}`
);
console.log(
` ${dim(' Exact precedence can vary by upstream backend/runtime version, so CCS does not promise one universal order.')}`
);
console.log('');
}
export async function handleRoutingStatus(): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Routing Strategy'));
console.log('');
const state = await readCliproxyRoutingState();
const [state, sessionAffinity] = await Promise.all([
readCliproxyRoutingState(),
readCliproxySessionAffinityState(),
]);
console.log(` Current: ${color(state.strategy, 'command')}`);
console.log(` Target: ${color(state.target, 'info')}`);
console.log(
@@ -39,8 +83,28 @@ export async function handleRoutingStatus(): Promise<void> {
console.log('');
console.log(infoBox(state.message, state.reachable ? 'INFO' : 'WARNING'));
}
console.log(
` Session Affinity: ${
sessionAffinity.manageable
? color(sessionAffinity.enabled ? 'on' : 'off', 'command')
: color('unsupported', 'warning')
}`
);
if (sessionAffinity.ttl) {
console.log(` Affinity TTL: ${color(sessionAffinity.ttl, 'info')}`);
}
if (sessionAffinity.message) {
console.log('');
console.log(
infoBox(
sessionAffinity.message,
sessionAffinity.manageable && sessionAffinity.reachable ? 'INFO' : 'WARNING'
)
);
}
console.log('');
printStrategyGuide();
printSessionAffinityGuide();
}
export async function handleRoutingExplain(): Promise<void> {
@@ -49,6 +113,8 @@ export async function handleRoutingExplain(): Promise<void> {
console.log(header('CLIProxy Routing Guide'));
console.log('');
printStrategyGuide();
printSessionAffinityGuide();
printSessionRecognitionGuide();
}
export async function handleRoutingSet(args: string[]): Promise<void> {
@@ -78,3 +144,105 @@ export async function handleRoutingSet(args: string[]): Promise<void> {
}
console.log('');
}
export async function handleRoutingAffinityStatus(): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Session Affinity'));
console.log('');
const state = await readCliproxySessionAffinityState();
if (!state.manageable) {
console.log(` Status: ${color('unsupported', 'warning')}`);
} else {
console.log(` Status: ${color(state.enabled ? 'on' : 'off', 'command')}`);
}
console.log(
` 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')}`);
}
if (state.message) {
console.log('');
console.log(infoBox(state.message, state.manageable && state.reachable ? 'INFO' : 'WARNING'));
}
console.log('');
printSessionAffinityGuide();
printSessionRecognitionGuide();
}
export async function handleRoutingAffinityHelp(): Promise<void> {
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<void> {
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) ||
remainingArgs.length > 0
) {
await initUI();
console.log('');
console.log(
fail('Invalid session affinity command. Use: routing affinity <on|off> [--ttl 1h]')
);
console.log('');
printSessionAffinityGuide();
console.log(` ${dim('Accepted TTL examples: 30m, 1h, 2h30m')}`);
console.log('');
process.exitCode = 1;
return;
}
await initUI();
console.log('');
console.log(header('Update CLIProxy Session Affinity'));
console.log('');
const result = await applyCliproxySessionAffinitySettings({
enabled: requested,
ttl,
});
if (!result.manageable) {
console.log(fail(result.message || 'Session affinity is not supported for this target.'));
console.log('');
process.exitCode = 1;
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('');
}
+18 -1
View File
@@ -168,10 +168,27 @@ 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']);
}
return completeSubcommands(['set', 'explain']);
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']);
}
if (['remove', 'edit'].includes(subcommand)) {
return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']);
+35
View File
@@ -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,31 @@ 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) || !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
*/
@@ -440,6 +467,14 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.cliproxy?.routing?.strategy === 'round-robin'
? partial.cliproxy.routing.strategy
: defaults.cliproxy.routing?.strategy,
session_affinity:
typeof partial.cliproxy?.routing?.session_affinity === 'boolean'
? partial.cliproxy.routing.session_affinity
: defaults.cliproxy.routing?.session_affinity,
session_affinity_ttl: normalizeSessionAffinityTtl(
partial.cliproxy?.routing?.session_affinity_ttl,
defaults.cliproxy.routing?.session_affinity_ttl ?? '1h'
),
},
},
proxy: {
+6
View File
@@ -205,6 +205,10 @@ export interface TokenRefreshSettings {
export interface CLIProxyRoutingConfig {
/** Credential selection strategy when multiple accounts match */
strategy?: CliproxyRoutingStrategy;
/** Keep one conversation pinned to the same account when possible */
session_affinity?: boolean;
/** Go-style duration for session-affinity binding retention */
session_affinity_ttl?: string;
}
/**
@@ -1034,6 +1038,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
auto_sync: true,
routing: {
strategy: 'round-robin',
session_affinity: false,
session_affinity_ttl: '1h',
},
},
proxy: {
@@ -1,8 +1,12 @@
import { Router, Request, Response } from 'express';
import {
applyCliproxyRoutingStrategy,
applyCliproxySessionAffinitySettings,
normalizeCliproxyRoutingStrategy,
normalizeCliproxySessionAffinityEnabled,
normalizeCliproxySessionAffinityTtl,
readCliproxyRoutingState,
readCliproxySessionAffinityState,
} from '../../cliproxy/routing-strategy';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
@@ -42,4 +46,42 @@ router.put('/routing/strategy', async (req: Request, res: Response): Promise<voi
}
});
router.get('/routing/session-affinity', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await readCliproxySessionAffinityState());
} catch (error) {
res.status(502).json({ error: (error as Error).message });
}
});
router.put('/routing/session-affinity', async (req: Request, res: Response): Promise<void> => {
const enabled = normalizeCliproxySessionAffinityEnabled(req.body?.enabled ?? req.body?.value);
const ttl = req.body?.ttl;
const normalizedTtl: string | undefined =
ttl === undefined ? undefined : (normalizeCliproxySessionAffinityTtl(ttl) ?? undefined);
if (enabled === null || (ttl !== undefined && !normalizedTtl)) {
res.status(400).json({
error: 'Invalid session affinity payload. Use enabled=true|false and ttl like 30m or 1h.',
});
return;
}
try {
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 });
}
});
export default router;