mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
Merge pull request #1117 from kaitranntt/kai/feat/1115-session-affinity
feat: add local CLIProxy session affinity controls
This commit is contained in:
21 files changed
+1231
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,9 @@ export interface CLIProxyConfig {
|
||||
debug: boolean;
|
||||
routing?: {
|
||||
strategy?: CliproxyRoutingStrategy;
|
||||
'session-affinity'?: boolean;
|
||||
'session-affinity-ttl'?: string;
|
||||
'claude-code-session-affinity'?: boolean;
|
||||
};
|
||||
'gemini-api-key'?: Array<{
|
||||
'api-key': string;
|
||||
|
||||
@@ -59,8 +59,10 @@ export async function showHelp(): Promise<void> {
|
||||
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
|
||||
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
|
||||
['routing', 'Show current routing strategy and manual guidance'],
|
||||
['routing explain', 'Explain round-robin vs fill-first'],
|
||||
['routing explain', 'Explain strategy vs session-affinity and how sessions are recognized'],
|
||||
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
|
||||
['routing affinity', 'Show local session-affinity status and TTL'],
|
||||
['routing affinity <on|off> [--ttl <duration>]', 'Toggle local session-affinity settings'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -34,7 +34,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;
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user