mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
Added: - 8 new open-sse services (account selector, IP filter, session manager, etc.) - 6 new dashboard settings tabs (IP filter, system prompt, thinking budget, pricing) - Usage cost dashboard with provider cost donut, cost trend line, model cost column - Pricing API merging registry + custom + pricing-only models - 9 unit tests for all new services Changed: - Usage analytics layout redesigned with prominent cost display - DailyTrendChart upgraded to ComposedChart with dual Y-axes Fixed: - Pricing page now shows custom/imported models - Icon rendering (material-symbols-rounded → outlined)
180 lines
4.9 KiB
JavaScript
180 lines
4.9 KiB
JavaScript
/**
|
|
* Session Fingerprinting — Phase 5
|
|
*
|
|
* Generates stable session IDs for sticky routing,
|
|
* prompt caching, and per-session tracking.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
// In-memory session store with metadata
|
|
// key: sessionId → { createdAt, lastActive, requestCount, connectionId? }
|
|
const sessions = new Map();
|
|
|
|
// 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, options = {}) {
|
|
const parts = [];
|
|
|
|
// 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, connectionId = null) {
|
|
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) {
|
|
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) {
|
|
const info = getSessionInfo(sessionId);
|
|
return info?.connectionId || null;
|
|
}
|
|
|
|
/**
|
|
* Get session count (for dashboard)
|
|
*/
|
|
export function getActiveSessionCount() {
|
|
return sessions.size;
|
|
}
|
|
|
|
/**
|
|
* Get all active sessions (for dashboard)
|
|
*/
|
|
export function getActiveSessions() {
|
|
const now = Date.now();
|
|
const result = [];
|
|
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() {
|
|
sessions.clear();
|
|
}
|
|
|
|
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
|
|
function hashShort(text) {
|
|
return createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
}
|
|
|
|
function extractSystemPrompt(body) {
|
|
// 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 (body.messages && 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) {
|
|
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;
|
|
}
|