* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * feat(proxy): add sticky session channel coordination --------- Co-authored-by: Cita <juricek.chen@gmail.com>
296 lines
8.9 KiB
TypeScript
296 lines
8.9 KiB
TypeScript
import { config } from '../config.js';
|
|
import {
|
|
getCredentialModeFromExtraConfig,
|
|
hasOauthProvider,
|
|
} from './accountExtraConfig.js';
|
|
|
|
type StickyEntry = {
|
|
channelId: number;
|
|
expiresAtMs: number;
|
|
};
|
|
|
|
type ActiveLeaseState = {
|
|
release: () => void;
|
|
};
|
|
|
|
type ChannelWaiter = {
|
|
cancelled: boolean;
|
|
resolve: (result: AcquireProxyChannelLeaseResult) => void;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
};
|
|
|
|
type ChannelRuntimeState = {
|
|
activeLeaseIds: Set<number>;
|
|
queue: ChannelWaiter[];
|
|
};
|
|
|
|
export type ProxyChannelLease = {
|
|
channelId: number;
|
|
isActive(): boolean;
|
|
release(): void;
|
|
touch(): void;
|
|
};
|
|
|
|
export type AcquireProxyChannelLeaseResult =
|
|
| { status: 'acquired'; lease: ProxyChannelLease }
|
|
| { status: 'timeout'; waitMs: number };
|
|
|
|
const stickySessionBindings = new Map<string, StickyEntry>();
|
|
const channelRuntimeStates = new Map<number, ChannelRuntimeState>();
|
|
let nextLeaseId = 1;
|
|
|
|
function shouldUnrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>) {
|
|
if (typeof (timer as { unref?: () => void }).unref === 'function') {
|
|
(timer as { unref: () => void }).unref();
|
|
}
|
|
}
|
|
|
|
function cleanupExpiredStickyBindings(nowMs = Date.now()): void {
|
|
for (const [key, entry] of stickySessionBindings.entries()) {
|
|
if (entry.expiresAtMs <= nowMs) {
|
|
stickySessionBindings.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isSessionScopedChannel(extraConfig?: string | null): boolean {
|
|
return getCredentialModeFromExtraConfig(extraConfig) === 'session'
|
|
|| hasOauthProvider(extraConfig);
|
|
}
|
|
|
|
function getStickySessionTtlMs(): number {
|
|
return Math.max(30_000, Math.trunc(config.proxyStickySessionTtlMs || 0));
|
|
}
|
|
|
|
function getChannelLeaseTtlMs(): number {
|
|
return Math.max(5_000, Math.trunc(config.proxySessionChannelLeaseTtlMs || 0));
|
|
}
|
|
|
|
function getChannelLeaseKeepaliveMs(): number {
|
|
return Math.max(1_000, Math.trunc(config.proxySessionChannelLeaseKeepaliveMs || 0));
|
|
}
|
|
|
|
function getChannelQueueWaitMs(): number {
|
|
return Math.max(0, Math.trunc(config.proxySessionChannelQueueWaitMs || 0));
|
|
}
|
|
|
|
function getChannelConcurrencyLimit(extraConfig?: string | null): number {
|
|
if (!isSessionScopedChannel(extraConfig)) return 0;
|
|
return Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0));
|
|
}
|
|
|
|
function getOrCreateChannelRuntimeState(channelId: number): ChannelRuntimeState {
|
|
let state = channelRuntimeStates.get(channelId);
|
|
if (!state) {
|
|
state = {
|
|
activeLeaseIds: new Set<number>(),
|
|
queue: [],
|
|
};
|
|
channelRuntimeStates.set(channelId, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function maybeDeleteChannelRuntimeState(channelId: number): void {
|
|
const state = channelRuntimeStates.get(channelId);
|
|
if (!state) return;
|
|
if (state.activeLeaseIds.size <= 0 && state.queue.every((waiter) => waiter.cancelled)) {
|
|
channelRuntimeStates.delete(channelId);
|
|
}
|
|
}
|
|
|
|
function createNoopLease(channelId: number): ProxyChannelLease {
|
|
return {
|
|
channelId,
|
|
isActive: () => false,
|
|
release: () => {},
|
|
touch: () => {},
|
|
};
|
|
}
|
|
|
|
class ProxyChannelCoordinator {
|
|
buildStickySessionKey(input: {
|
|
clientKind?: string | null;
|
|
sessionId?: string | null;
|
|
requestedModel: string;
|
|
downstreamPath: string;
|
|
downstreamApiKeyId?: number | null;
|
|
}): string | null {
|
|
if (!config.proxyStickySessionEnabled) return null;
|
|
const sessionId = String(input.sessionId || '').trim();
|
|
if (!sessionId) return null;
|
|
const requestedModel = String(input.requestedModel || '').trim().toLowerCase();
|
|
if (!requestedModel) return null;
|
|
const downstreamPath = String(input.downstreamPath || '').trim().toLowerCase() || 'unknown';
|
|
const clientKind = String(input.clientKind || 'generic').trim().toLowerCase() || 'generic';
|
|
const owner = typeof input.downstreamApiKeyId === 'number' && Number.isFinite(input.downstreamApiKeyId)
|
|
? `key:${Math.trunc(input.downstreamApiKeyId)}`
|
|
: 'key:anonymous';
|
|
return [owner, clientKind, downstreamPath, requestedModel, sessionId].join('|');
|
|
}
|
|
|
|
getStickyChannelId(stickySessionKey?: string | null, nowMs = Date.now()): number | null {
|
|
cleanupExpiredStickyBindings(nowMs);
|
|
const normalizedKey = String(stickySessionKey || '').trim();
|
|
if (!normalizedKey) return null;
|
|
const entry = stickySessionBindings.get(normalizedKey);
|
|
if (!entry || entry.expiresAtMs <= nowMs) {
|
|
stickySessionBindings.delete(normalizedKey);
|
|
return null;
|
|
}
|
|
return entry.channelId;
|
|
}
|
|
|
|
bindStickyChannel(stickySessionKey: string | null | undefined, channelId: number, extraConfig?: string | null): void {
|
|
if (!config.proxyStickySessionEnabled) return;
|
|
if (!isSessionScopedChannel(extraConfig)) return;
|
|
const normalizedKey = String(stickySessionKey || '').trim();
|
|
if (!normalizedKey || !Number.isFinite(channelId) || channelId <= 0) return;
|
|
cleanupExpiredStickyBindings();
|
|
stickySessionBindings.set(normalizedKey, {
|
|
channelId: Math.trunc(channelId),
|
|
expiresAtMs: Date.now() + getStickySessionTtlMs(),
|
|
});
|
|
}
|
|
|
|
clearStickyChannel(stickySessionKey?: string | null, channelId?: number | null): void {
|
|
const normalizedKey = String(stickySessionKey || '').trim();
|
|
if (!normalizedKey) return;
|
|
const existing = stickySessionBindings.get(normalizedKey);
|
|
if (!existing) return;
|
|
if (typeof channelId === 'number' && Number.isFinite(channelId) && existing.channelId !== Math.trunc(channelId)) {
|
|
return;
|
|
}
|
|
stickySessionBindings.delete(normalizedKey);
|
|
}
|
|
|
|
async acquireChannelLease(input: {
|
|
channelId: number;
|
|
accountExtraConfig?: string | null;
|
|
}): Promise<AcquireProxyChannelLeaseResult> {
|
|
const channelId = Math.trunc(input.channelId || 0);
|
|
if (channelId <= 0) {
|
|
return {
|
|
status: 'acquired',
|
|
lease: createNoopLease(0),
|
|
};
|
|
}
|
|
|
|
const concurrencyLimit = getChannelConcurrencyLimit(input.accountExtraConfig);
|
|
if (concurrencyLimit <= 0) {
|
|
return {
|
|
status: 'acquired',
|
|
lease: createNoopLease(channelId),
|
|
};
|
|
}
|
|
|
|
const state = getOrCreateChannelRuntimeState(channelId);
|
|
if (state.activeLeaseIds.size < concurrencyLimit) {
|
|
return {
|
|
status: 'acquired',
|
|
lease: this.createTrackedLease(channelId, state),
|
|
};
|
|
}
|
|
|
|
const waitMs = getChannelQueueWaitMs();
|
|
if (waitMs <= 0) {
|
|
return {
|
|
status: 'timeout',
|
|
waitMs: 0,
|
|
};
|
|
}
|
|
|
|
return await new Promise<AcquireProxyChannelLeaseResult>((resolve) => {
|
|
const waiter: ChannelWaiter = {
|
|
cancelled: false,
|
|
resolve,
|
|
timer: null,
|
|
};
|
|
waiter.timer = setTimeout(() => {
|
|
waiter.cancelled = true;
|
|
waiter.timer = null;
|
|
maybeDeleteChannelRuntimeState(channelId);
|
|
resolve({
|
|
status: 'timeout',
|
|
waitMs,
|
|
});
|
|
}, waitMs);
|
|
shouldUnrefTimer(waiter.timer);
|
|
state.queue.push(waiter);
|
|
});
|
|
}
|
|
|
|
private createTrackedLease(channelId: number, state: ChannelRuntimeState): ProxyChannelLease {
|
|
const leaseId = nextLeaseId++;
|
|
state.activeLeaseIds.add(leaseId);
|
|
|
|
let released = false;
|
|
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const release = () => {
|
|
if (released) return;
|
|
released = true;
|
|
if (expiryTimer) clearTimeout(expiryTimer);
|
|
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
state.activeLeaseIds.delete(leaseId);
|
|
this.drainQueue(channelId);
|
|
maybeDeleteChannelRuntimeState(channelId);
|
|
};
|
|
|
|
const touch = () => {
|
|
if (released) return;
|
|
if (expiryTimer) clearTimeout(expiryTimer);
|
|
expiryTimer = setTimeout(() => {
|
|
release();
|
|
}, getChannelLeaseTtlMs());
|
|
shouldUnrefTimer(expiryTimer);
|
|
};
|
|
|
|
touch();
|
|
|
|
const keepaliveMs = getChannelLeaseKeepaliveMs();
|
|
if (keepaliveMs > 0) {
|
|
keepaliveTimer = setInterval(() => {
|
|
touch();
|
|
}, keepaliveMs);
|
|
shouldUnrefTimer(keepaliveTimer);
|
|
}
|
|
|
|
return {
|
|
channelId,
|
|
isActive: () => !released,
|
|
release,
|
|
touch,
|
|
};
|
|
}
|
|
|
|
private drainQueue(channelId: number): void {
|
|
const state = channelRuntimeStates.get(channelId);
|
|
if (!state) return;
|
|
const concurrencyLimit = Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0));
|
|
while (state.activeLeaseIds.size < concurrencyLimit && state.queue.length > 0) {
|
|
const waiter = state.queue.shift();
|
|
if (!waiter || waiter.cancelled) continue;
|
|
if (waiter.timer) clearTimeout(waiter.timer);
|
|
waiter.timer = null;
|
|
waiter.resolve({
|
|
status: 'acquired',
|
|
lease: this.createTrackedLease(channelId, state),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resetProxyChannelCoordinatorState(): void {
|
|
stickySessionBindings.clear();
|
|
channelRuntimeStates.clear();
|
|
nextLeaseId = 1;
|
|
}
|
|
|
|
export function isProxyChannelSessionScoped(extraConfig?: string | null): boolean {
|
|
return isSessionScopedChannel(extraConfig);
|
|
}
|
|
|
|
export const proxyChannelCoordinator = new ProxyChannelCoordinator();
|