Files
OmniRoute/open-sse/services/sessionManager.ts
diegosouzapw 7a7f3be0d2 feat(sub2api): implement T01-T15 gap analysis tasks (3.0.0-rc.6)
T01 (P1): requested_model column in call_logs
- Migration 009_requested_model.sql: ALTER TABLE call_logs ADD COLUMN requested_model
- callLogs.ts: INSERT + SELECT updated to include requestedModel field

T02 (P1): Strip empty text blocks from nested tool_result.content
- New stripEmptyTextBlocks() recursive helper in openai-to-claude.ts
- Applied on tool_result content before forwarding to Anthropic
- Prevents 400 'text content blocks must be non-empty' errors

T03 (P1): Parse x-codex-5h-*/x-codex-7d-* headers for precise quota reset
- parseCodexQuotaHeaders() in codex.ts extracts usage/limit/resetAt
- getCodexResetTime() returns furthest-out reset timestamp for safe unblocking

T04 (P1): X-Session-Id header for external sticky routing
- extractExternalSessionId() in sessionManager.ts reads x-session-id,
  x-omniroute-session, session-id headers with 'ext:' prefix to avoid collisions

T06 (P2): account_deactivated permanent expired status on 401
- ACCOUNT_DEACTIVATED_SIGNALS constant + isAccountDeactivated() in accountFallback.ts
- Returns 1-year cooldown (effectively permanent) to prevent retrying dead accounts

T07 (P2): X-Forwarded-For IP validation
- New src/lib/ipUtils.ts with extractClientIp() and getClientIpFromRequest()
- Skips 'unknown'/non-IP entries in X-Forwarded-For chain

T10 (P2): credits_exhausted distinct account status
- CREDITS_EXHAUSTED_SIGNALS + isCreditsExhausted() in accountFallback.ts
- Returns 1h cooldown with creditsExhausted flag, distinct from rate_limit 429

T11 (P1): max reasoning_effort -> budget_tokens: 131072
- EFFORT_BUDGETS and THINKING_LEVEL_MAP updated with max: 131072, xhigh: 131072
- Reverse mapping now returns 'max' for full-budget responses
- Unit test updated to expect 'max' (was 'high')

T12 (P3): Model pricing updates
- MiniMax M2.7 / MiniMax-M2.7 / minimax-m2.7-highspeed pricing added

T15 (P1): Array content normalization for system/tool messages
- normalizeContentToString() helper exported from openai-to-claude.ts
- System messages with array content now correctly collapsed to string
2026-03-22 20:55:35 -03:00

237 lines
7.1 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;
}
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>();
// Auto-cleanup sessions older than 30 minutes
const SESSION_TTL_MS = 30 * 60 * 1000;
const _cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessions) {
if (now - entry.lastActive > SESSION_TTL_MS) sessions.delete(key);
}
}, 60_000);
_cleanupTimer.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,
});
}
}
/**
* 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();
}
/**
* 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-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;
}