/** * In-memory sliding-window rate limiter. PolyLLM runs as a single PM2 process on the * cluster so a process-local limiter is sufficient; the interface is async so a Redis * store can be dropped in without touching call sites. */ interface Bucket { hits: number[]; } const buckets = new Map(); let lastSweep = Date.now(); function sweep(now: number) { if (now - lastSweep < 60_000) return; lastSweep = now; for (const [k, b] of buckets) { if (b.hits.length === 0 || b.hits[b.hits.length - 1] < now - 10 * 60_000) buckets.delete(k); } } export interface RateLimitResult { ok: boolean; remaining: number; retryAfterMs: number; } export async function rateLimit(key: string, max: number, windowMs: number): Promise { const now = Date.now(); sweep(now); const b = buckets.get(key) ?? { hits: [] }; b.hits = b.hits.filter((t) => t > now - windowMs); if (b.hits.length >= max) { buckets.set(key, b); return { ok: false, remaining: 0, retryAfterMs: b.hits[0] + windowMs - now }; } b.hits.push(now); buckets.set(key, b); return { ok: true, remaining: max - b.hits.length, retryAfterMs: 0 }; } export function clientIp(req: Request): string { const h = req.headers; // ngrok forwards the visitor IP in X-Forwarded-For (first hop). const xff = h.get("x-forwarded-for"); if (xff) return xff.split(",")[0].trim(); return h.get("x-real-ip") ?? "unknown"; } export const LIMITS = { chat: { max: 60, windowMs: 60_000 }, arena: { max: 20, windowMs: 60_000 }, keyValidate: { max: 10, windowMs: 60_000 }, keySave: { max: 12, windowMs: 60_000 }, modelSync: { max: 6, windowMs: 60_000 }, upload: { max: 40, windowMs: 60_000 }, search: { max: 120, windowMs: 60_000 }, share: { max: 20, windowMs: 60_000 }, } as const;