381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
/**
|
|
* Session Fingerprinting — Phase 5
|
|
*
|
|
* Generates stable session IDs for sticky routing,
|
|
* prompt caching, and per-session tracking.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
interface SessionEntry {
|
|
createdAt: number;
|
|
lastActive: number;
|
|
requestCount: number;
|
|
connectionId: string | null;
|
|
/** Timestamp when the last tool_calls finish_reason was emitted (cross-request TTFT tracking) */
|
|
lastToolFinishAt?: number;
|
|
}
|
|
|
|
interface SessionFingerprintOptions {
|
|
provider?: string;
|
|
connectionId?: string;
|
|
}
|
|
|
|
interface SessionMessage {
|
|
role?: string;
|
|
content?: unknown;
|
|
}
|
|
|
|
interface SessionBody {
|
|
model?: string;
|
|
system?: unknown;
|
|
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
|
messages?: SessionMessage[];
|
|
input?: SessionMessage[];
|
|
}
|
|
|
|
// In-memory session store with metadata
|
|
// key: sessionId → { createdAt, lastActive, requestCount, connectionId? }
|
|
const sessions = new Map<string, SessionEntry>();
|
|
|
|
// Hard cap on active sessions to prevent memory exhaustion
|
|
const MAX_SESSIONS = 200;
|
|
|
|
// Auto-cleanup sessions older than 15 minutes (reduced from 30)
|
|
const SESSION_TTL_MS = 15 * 60 * 1000;
|
|
const _cleanupTimer = setInterval(() => {
|
|
const now = Date.now();
|
|
// Evict expired sessions
|
|
for (const [key, entry] of sessions) {
|
|
if (now - entry.lastActive > SESSION_TTL_MS) {
|
|
sessions.delete(key);
|
|
const keysToDelete: string[] = [];
|
|
for (const [apiKeyId, sessionSet] of activeSessionsByKey) {
|
|
sessionSet.delete(key);
|
|
if (sessionSet.size === 0) keysToDelete.push(apiKeyId);
|
|
}
|
|
for (const k of keysToDelete) {
|
|
activeSessionsByKey.delete(k);
|
|
}
|
|
}
|
|
}
|
|
// Hard cap: evict oldest if over limit
|
|
while (sessions.size > MAX_SESSIONS) {
|
|
let oldestKey: string | null = null;
|
|
let oldestTime = Infinity;
|
|
for (const [key, entry] of sessions) {
|
|
if (entry.lastActive < oldestTime) {
|
|
oldestTime = entry.lastActive;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
if (oldestKey === null) break;
|
|
sessions.delete(oldestKey);
|
|
const evictionKeys: string[] = [];
|
|
for (const [apiKeyId, sessionSet] of activeSessionsByKey) {
|
|
sessionSet.delete(oldestKey);
|
|
if (sessionSet.size === 0) evictionKeys.push(apiKeyId);
|
|
}
|
|
for (const k of evictionKeys) {
|
|
activeSessionsByKey.delete(k);
|
|
}
|
|
}
|
|
}, 60_000);
|
|
if (typeof _cleanupTimer === "object" && "unref" in _cleanupTimer) {
|
|
(_cleanupTimer as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
/**
|
|
* Generate a stable session fingerprint from request characteristics.
|
|
* Same client + same conversation → same session ID.
|
|
*
|
|
* Fingerprint factors:
|
|
* - System prompt hash (stable per conversation/tool)
|
|
* - First user message hash (stable per conversation)
|
|
* - Model name
|
|
* - Provider (optional)
|
|
* - Tools signature (sorted tool names)
|
|
*
|
|
* @param {object} body - Request body
|
|
* @param {object} [options] - Extra context
|
|
* @returns {string} Session ID (hex hash)
|
|
*/
|
|
export function generateSessionId(
|
|
body: SessionBody | null | undefined,
|
|
options: SessionFingerprintOptions = {}
|
|
): string | null {
|
|
if (!body || typeof body !== "object") return null;
|
|
const parts: string[] = [];
|
|
|
|
// Model contributes to fingerprint
|
|
if (body.model) parts.push(`model:${body.model}`);
|
|
|
|
// Provider binding
|
|
if (options.provider) parts.push(`provider:${options.provider}`);
|
|
|
|
// System prompt hash (first 32 chars of system content)
|
|
const systemPrompt = extractSystemPrompt(body);
|
|
if (systemPrompt) {
|
|
parts.push(`sys:${hashShort(systemPrompt)}`);
|
|
}
|
|
|
|
// First user message hash (identifies the conversation)
|
|
const firstUser = extractFirstUserMessage(body);
|
|
if (firstUser) {
|
|
parts.push(`user0:${hashShort(firstUser)}`);
|
|
}
|
|
|
|
// Tools signature (sorted names)
|
|
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
|
const toolNames = body.tools
|
|
.map((t) => t.name || t.function?.name || "")
|
|
.filter(Boolean)
|
|
.sort()
|
|
.join(",");
|
|
if (toolNames) parts.push(`tools:${hashShort(toolNames)}`);
|
|
}
|
|
|
|
// Connection ID for sticky routing
|
|
if (options.connectionId) parts.push(`conn:${options.connectionId}`);
|
|
|
|
if (parts.length === 0) return null;
|
|
|
|
const fingerprint = parts.join("|");
|
|
return createHash("sha256").update(fingerprint).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
/**
|
|
* Touch or create a session
|
|
*/
|
|
export function touchSession(sessionId: string | null, connectionId: string | null = null): void {
|
|
if (!sessionId) return;
|
|
const existing = sessions.get(sessionId);
|
|
if (existing) {
|
|
existing.lastActive = Date.now();
|
|
existing.requestCount++;
|
|
if (connectionId) existing.connectionId = connectionId;
|
|
} else {
|
|
sessions.set(sessionId, {
|
|
createdAt: Date.now(),
|
|
lastActive: Date.now(),
|
|
requestCount: 1,
|
|
connectionId,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist the tool-finish timestamp so Request 2 (the follow-up after tool execution)
|
|
* can measure cross-request TTFT.
|
|
*/
|
|
export function markToolFinish(sessionId: string | null): void {
|
|
if (!sessionId) return;
|
|
const session = sessions.get(sessionId);
|
|
if (session) session.lastToolFinishAt = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Consume the previously persisted tool-finish timestamp (one-shot: clears after read).
|
|
* Returns null if no pending timestamp or session not found.
|
|
*/
|
|
export function consumeToolFinishTime(sessionId: string | null): number | null {
|
|
if (!sessionId) return null;
|
|
const session = sessions.get(sessionId);
|
|
if (!session?.lastToolFinishAt) return null;
|
|
const ts = session.lastToolFinishAt;
|
|
session.lastToolFinishAt = undefined;
|
|
return ts;
|
|
}
|
|
|
|
/**
|
|
* Get session info (for sticky routing decisions)
|
|
*/
|
|
export function getSessionInfo(sessionId: string | null): SessionEntry | null {
|
|
if (!sessionId) return null;
|
|
const entry = sessions.get(sessionId);
|
|
if (!entry) return null;
|
|
if (Date.now() - entry.lastActive > SESSION_TTL_MS) {
|
|
sessions.delete(sessionId);
|
|
return null;
|
|
}
|
|
return { ...entry };
|
|
}
|
|
|
|
/**
|
|
* Get the bound connection for a session (sticky routing)
|
|
*/
|
|
export function getSessionConnection(sessionId: string | null): string | null {
|
|
const info = getSessionInfo(sessionId);
|
|
return info?.connectionId || null;
|
|
}
|
|
|
|
/**
|
|
* Get session count (for dashboard)
|
|
*/
|
|
export function getActiveSessionCount(): number {
|
|
return sessions.size;
|
|
}
|
|
|
|
/**
|
|
* Get all active sessions (for dashboard)
|
|
*/
|
|
export function getActiveSessions(): Array<SessionEntry & { sessionId: string; ageMs: number }> {
|
|
const now = Date.now();
|
|
const result: Array<SessionEntry & { sessionId: string; ageMs: number }> = [];
|
|
for (const [id, entry] of sessions) {
|
|
if (now - entry.lastActive <= SESSION_TTL_MS) {
|
|
result.push({ sessionId: id, ...entry, ageMs: now - entry.createdAt });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Clear all sessions (for testing)
|
|
*/
|
|
export function clearSessions(): void {
|
|
sessions.clear();
|
|
activeSessionsByKey.clear();
|
|
}
|
|
|
|
// ─── T08: Per-API-Key Session Limit ─────────────────────────────────────────
|
|
// Tracks concurrent sticky sessions per API key and enforces max_sessions limits.
|
|
// Ref: sub2api PR #634 (fix: stabilize session hash + add user-level session limit)
|
|
|
|
// Map: apiKeyId → Set<sessionId>
|
|
const activeSessionsByKey = new Map<string, Set<string>>();
|
|
|
|
/**
|
|
* T08: Get the number of currently active sessions for an API key.
|
|
* @param apiKeyId - The API key's UUID from the database
|
|
*/
|
|
export function getActiveSessionCountForKey(apiKeyId: string): number {
|
|
return activeSessionsByKey.get(apiKeyId)?.size ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Snapshot of active session counts per API key.
|
|
*/
|
|
export function getAllActiveSessionCountsByKey(): Record<string, number> {
|
|
const out: Record<string, number> = {};
|
|
for (const [apiKeyId, sessionIds] of activeSessionsByKey) {
|
|
out[apiKeyId] = sessionIds.size;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* T08: Register a session as belonging to an API key.
|
|
* Call this after session creation is allowed (i.e., limit check passed).
|
|
*/
|
|
export function registerKeySession(apiKeyId: string, sessionId: string): void {
|
|
if (!activeSessionsByKey.has(apiKeyId)) {
|
|
activeSessionsByKey.set(apiKeyId, new Set());
|
|
}
|
|
activeSessionsByKey.get(apiKeyId)!.add(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Check whether a given session is already registered for an API key.
|
|
*/
|
|
export function isSessionRegisteredForKey(apiKeyId: string, sessionId: string): boolean {
|
|
return activeSessionsByKey.get(apiKeyId)?.has(sessionId) === true;
|
|
}
|
|
|
|
/**
|
|
* T08: Unregister a session from an API key's active set.
|
|
* Call this when the request closes or the session TTL expires.
|
|
*/
|
|
export function unregisterKeySession(apiKeyId: string, sessionId: string): void {
|
|
activeSessionsByKey.get(apiKeyId)?.delete(sessionId);
|
|
// Clean up empty sets to avoid memory leaks
|
|
if (activeSessionsByKey.get(apiKeyId)?.size === 0) {
|
|
activeSessionsByKey.delete(apiKeyId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* T08: Check whether adding a new session would exceed the key's max_sessions limit.
|
|
* Returns null if allowed, or an error object to return as a 429 response.
|
|
*
|
|
* @param apiKeyId - The API key's UUID
|
|
* @param maxSessions - The limit from the DB (0 = unlimited)
|
|
*/
|
|
export function checkSessionLimit(
|
|
apiKeyId: string,
|
|
maxSessions: number
|
|
): { code: "SESSION_LIMIT_EXCEEDED"; message: string; limit: number; current: number } | null {
|
|
if (!maxSessions || maxSessions <= 0) return null; // unlimited
|
|
const current = getActiveSessionCountForKey(apiKeyId);
|
|
if (current < maxSessions) return null;
|
|
return {
|
|
code: "SESSION_LIMIT_EXCEEDED",
|
|
message:
|
|
`You have reached the maximum number of active sessions (${maxSessions}). ` +
|
|
`Please close unused sessions or wait for them to expire.`,
|
|
limit: maxSessions,
|
|
current,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* T04: Extract an external session ID from request headers.
|
|
* Accepts both hyphenated and underscore forms for Nginx compatibility.
|
|
* Nginx drops headers with underscores by default — use `underscores_in_headers on`
|
|
* in nginx.conf, or use X-Session-Id (hyphenated) which passes cleanly.
|
|
*
|
|
* Ref: sub2api README + PR #634
|
|
*
|
|
* @param headers - Request headers (Headers object or plain object with .get())
|
|
* @returns External session ID with "ext:" prefix, or null
|
|
*/
|
|
export function extractExternalSessionId(
|
|
headers: Headers | { get?: (n: string) => string | null } | null | undefined
|
|
): string | null {
|
|
if (!headers || typeof (headers as Headers).get !== "function") return null;
|
|
const h = headers as Headers;
|
|
const raw =
|
|
h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx)
|
|
h.get("x_session_id") ?? // Underscore variant (direct HTTP / custom clients)
|
|
h.get("x-omniroute-session") ?? // OmniRoute-specific form
|
|
h.get("session-id") ?? // Bare session-id
|
|
null;
|
|
if (!raw || !raw.trim()) return null;
|
|
// Prefix "ext:" to ensure no collision with internal SHA-256 hash IDs
|
|
return `ext:${raw.trim().slice(0, 64)}`; // max 64 chars to avoid abuse
|
|
}
|
|
|
|
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
|
|
function hashShort(text: string): string {
|
|
return createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
}
|
|
|
|
function extractSystemPrompt(body: SessionBody | null | undefined): string | null {
|
|
if (!body || typeof body !== "object") return null;
|
|
// Claude format: body.system
|
|
if (body.system) {
|
|
return typeof body.system === "string" ? body.system : JSON.stringify(body.system);
|
|
}
|
|
// OpenAI format: messages[0].role === "system"
|
|
if (Array.isArray(body.messages)) {
|
|
const sys = body.messages.find((m) => m.role === "system" || m.role === "developer");
|
|
if (sys) {
|
|
return typeof sys.content === "string" ? sys.content : JSON.stringify(sys.content);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractFirstUserMessage(body: SessionBody | null | undefined): string | null {
|
|
if (!body || typeof body !== "object") return null;
|
|
const messages = body.messages || body.input || [];
|
|
if (!Array.isArray(messages)) return null;
|
|
for (const msg of messages) {
|
|
if (msg.role === "user") {
|
|
return typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
|
|
}
|
|
}
|
|
return null;
|
|
}
|