FreeLLMAPI / server /dist /services /ratelimit.js
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
13.8 kB
// Sliding window rate limit tracker with SQLite persistence.
import { getDb } from '../db/index.js';
// Key format: "platform:modelId:keyId:type" where type is rpm|rpd|tpm|tpd
const windows = new Map();
function getWindow(key) {
let w = windows.get(key);
if (!w) {
w = { timestamps: [], tokenCount: 0, tokenTimestamps: [] };
windows.set(key, w);
}
return w;
}
function pruneTimestamps(timestamps, windowMs, now) {
const cutoff = now - windowMs;
return timestamps.filter(ts => ts > cutoff);
}
const MINUTE = 60 * 1000;
const DAY = 24 * 60 * MINUTE;
function withDb(fn) {
try {
return fn(getDb());
}
catch {
return undefined;
}
}
function recordUsage(platform, modelId, keyId, kind, tokens, now) {
withDb(db => {
db.prepare(`
INSERT INTO rate_limit_usage (platform, model_id, key_id, kind, tokens, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
`).run(platform, modelId, keyId, kind, tokens, now);
db.prepare('DELETE FROM rate_limit_usage WHERE created_at_ms <= ?').run(now - DAY);
});
}
function countPersistedRequests(platform, modelId, keyId, windowMs, now) {
return withDb(db => {
const row = db.prepare(`
SELECT COUNT(*) AS used
FROM rate_limit_usage
WHERE platform = ?
AND model_id = ?
AND key_id = ?
AND kind = 'request'
AND created_at_ms > ?
`).get(platform, modelId, keyId, now - windowMs);
return row.used;
});
}
function sumPersistedTokens(platform, modelId, keyId, windowMs, now) {
return withDb(db => {
const row = db.prepare(`
SELECT COALESCE(SUM(tokens), 0) AS used
FROM rate_limit_usage
WHERE platform = ?
AND model_id = ?
AND key_id = ?
AND kind = 'tokens'
AND created_at_ms > ?
`).get(platform, modelId, keyId, now - windowMs);
return row.used;
});
}
function memoryRequestCount(key, windowMs, now) {
const w = getWindow(key);
w.timestamps = pruneTimestamps(w.timestamps, windowMs, now);
return w.timestamps.length;
}
function memoryTokenCount(key, windowMs, now) {
const w = getWindow(key);
w.tokenTimestamps = w.tokenTimestamps.filter(t => t.ts > now - windowMs);
return w.tokenTimestamps.reduce((sum, t) => sum + t.tokens, 0);
}
function requestCount(platform, modelId, keyId, windowMs, now) {
const persisted = countPersistedRequests(platform, modelId, keyId, windowMs, now);
if (persisted !== undefined)
return persisted;
const type = windowMs === MINUTE ? 'rpm' : 'rpd';
return memoryRequestCount(`${platform}:${modelId}:${keyId}:${type}`, windowMs, now);
}
function tokenCount(platform, modelId, keyId, windowMs, now) {
const persisted = sumPersistedTokens(platform, modelId, keyId, windowMs, now);
if (persisted !== undefined)
return persisted;
const type = windowMs === MINUTE ? 'tpm' : 'tpd';
return memoryTokenCount(`${platform}:${modelId}:${keyId}:${type}`, windowMs, now);
}
export function canMakeRequest(platform, modelId, keyId, limits) {
const now = Date.now();
if (limits.rpm !== null) {
if (requestCount(platform, modelId, keyId, MINUTE, now) >= limits.rpm)
return false;
}
if (limits.rpd !== null) {
if (requestCount(platform, modelId, keyId, DAY, now) >= limits.rpd)
return false;
}
return true;
}
export function canUseTokens(platform, modelId, keyId, estimatedTokens, limits) {
const now = Date.now();
if (limits.tpm !== null) {
const used = tokenCount(platform, modelId, keyId, MINUTE, now);
if (used + estimatedTokens > limits.tpm)
return false;
}
if (limits.tpd !== null) {
const used = tokenCount(platform, modelId, keyId, DAY, now);
if (used + estimatedTokens > limits.tpd)
return false;
}
return true;
}
// ── Provider-wide daily request caps (#162) ──
// Some providers enforce one daily REQUEST quota across the WHOLE account,
// shared by every model β€” not per model. OpenRouter's free tier is the classic
// case: ~1000 requests/day total (50/day if you've bought <10 credits) no
// matter how many different free models you spread them across. The
// per-(platform,model,key) rpd ledger can't see that, so without a provider-wide
// gate the router happily fires (models Γ— rpd) requests and earns surprise 429s.
//
// Defaults below; override per provider with an env var, e.g.
// PROVIDER_DAILY_REQUEST_CAP_OPENROUTER=50 (set 0 to disable the cap)
const DEFAULT_PROVIDER_DAILY_REQUEST_CAPS = {
openrouter: 1000,
};
export function getProviderDailyRequestCap(platform) {
const raw = process.env[`PROVIDER_DAILY_REQUEST_CAP_${platform.toUpperCase()}`];
if (raw !== undefined && raw.trim() !== '') {
const n = Number(raw);
if (Number.isFinite(n) && n >= 0)
return n === 0 ? null : n;
}
return DEFAULT_PROVIDER_DAILY_REQUEST_CAPS[platform] ?? null;
}
function countPersistedProviderRequests(platform, keyId, windowMs, now) {
return withDb(db => {
const row = db.prepare(`
SELECT COUNT(*) AS used
FROM rate_limit_usage
WHERE platform = ?
AND key_id = ?
AND kind = 'request'
AND created_at_ms > ?
`).get(platform, keyId, now - windowMs);
return row.used;
});
}
// Total requests today for a provider account+key, summed across every model.
export function providerDailyRequestCount(platform, keyId, now = Date.now()) {
const persisted = countPersistedProviderRequests(platform, keyId, DAY, now);
if (persisted !== undefined)
return persisted;
// DB-unavailable fallback: sum the per-model rpd windows for this platform+key.
// Window key format is "platform:modelId:keyId:rpd" (modelId may contain ':').
let total = 0;
for (const [key, w] of windows) {
if (key.startsWith(`${platform}:`) && key.endsWith(`:${keyId}:rpd`)) {
total += pruneTimestamps(w.timestamps, DAY, now).length;
}
}
return total;
}
// False when this provider account+key has hit its shared daily request cap, so
// the router skips every model on that provider for this key until UTC-ish reset.
export function canUseProvider(platform, keyId, now = Date.now()) {
const cap = getProviderDailyRequestCap(platform);
if (cap === null)
return true;
return providerDailyRequestCount(platform, keyId, now) < cap;
}
export function recordRequest(platform, modelId, keyId) {
const now = Date.now();
const rpmKey = `${platform}:${modelId}:${keyId}:rpm`;
getWindow(rpmKey).timestamps.push(now);
const rpdKey = `${platform}:${modelId}:${keyId}:rpd`;
getWindow(rpdKey).timestamps.push(now);
recordUsage(platform, modelId, keyId, 'request', 0, now);
}
export function recordTokens(platform, modelId, keyId, tokens) {
const now = Date.now();
const tpmKey = `${platform}:${modelId}:${keyId}:tpm`;
getWindow(tpmKey).tokenTimestamps.push({ ts: now, tokens });
const tpdKey = `${platform}:${modelId}:${keyId}:tpd`;
getWindow(tpdKey).tokenTimestamps.push({ ts: now, tokens });
recordUsage(platform, modelId, keyId, 'tokens', tokens, now);
}
// Cooldown: when a provider returns 429, block that model+key for a period
const cooldowns = new Map(); // key -> expiry timestamp
// Escalating cooldown: track hits per key over a rolling 24h window so a
// daily-quota exhaustion (OpenRouter free: 50/day, Cohere free: 33/day, etc.)
// quarantines the key for the rest of the day instead of looping through
// the 2-minute cooldown 20 times per request and consuming every fallback slot.
// In-memory only β€” state resets on restart, which is fine (a clean restart
// will re-escalate on the next 429 if the quota is genuinely exhausted).
const cooldownHits = new Map(); // key -> timestamps of recent cooldown set events
const HOUR = 60 * MINUTE;
const COOLDOWN_DURATIONS = [
2 * MINUTE, // 1st hit in 24h
10 * MINUTE, // 2nd
HOUR, // 3rd
DAY, // 4th and beyond
];
export function getNextCooldownDuration(platform, modelId, keyId) {
const key = `${platform}:${modelId}:${keyId}`;
const now = Date.now();
const hits = (cooldownHits.get(key) ?? []).filter(t => t > now - DAY);
hits.push(now);
cooldownHits.set(key, hits);
const idx = Math.min(hits.length - 1, COOLDOWN_DURATIONS.length - 1);
return COOLDOWN_DURATIONS[idx];
}
// Short cooldown for a transient (per-minute) 429 β€” recovers within ~one window.
const TRANSIENT_COOLDOWN_MS = 90 * 1000;
// Long cooldown for a 402 Payment Required (provider/key out of credits). Unlike
// a 429, this won't clear on the next minute/day window β€” it needs a top-up or
// billing reset. Bench the model+key for a full day so the router fails over to
// other providers instead of re-hammering a dead key every retry. Re-escalates
// on the next 402 after expiry if still unpaid; a restart re-benches on first hit.
export const PAYMENT_REQUIRED_COOLDOWN_MS = DAY;
// Long cooldown for a 403 Forbidden on a key that already passed validateKey
// (so it is not a dead key β€” the health checker disables those). A request-time
// 403 means this key's tier can't reach this specific model (e.g. gpt-4o on
// GitHub Models' free tier, subscription-only models on Cloudflare). That won't
// change within a minute window, so bench this model+key for a full day and let
// the router fail over to a model the key can actually serve. See issue #256.
export const MODEL_FORBIDDEN_COOLDOWN_MS = DAY;
// Decide how long to bench a model+key after an upstream 429. Escalate to the
// long quarantine (getNextCooldownDuration, up to 24h) ONLY when the model is
// genuinely at its DAILY limit (RPD or TPD) β€” that won't recover until the
// provider's daily reset, so a long bench avoids hammering a truly-dead key.
//
// A transient RPM/TPM 429 gets a short fixed cooldown and does NOT count toward
// escalation. This is the common case for providers with a tight per-minute
// token budget but a large daily quota β€” e.g. groq gpt-oss-120b has rpd=1000
// yet tpm=8000, so a single burst of large prompts 429s on TPM while the daily
// quota is barely touched. Without this split, those transient bursts escalated
// (2m β†’ 10m β†’ 1h β†’ 24h) and quarantined a perfectly healthy provider for the
// rest of the day. Daily counters are persisted (countPersistedRequests /
// sumPersistedTokens), so this verdict is stable across restarts.
export function getCooldownDurationForLimit(platform, modelId, keyId, limits, retryAfterMs) {
const now = Date.now();
const rpdExhausted = limits.rpd !== null && requestCount(platform, modelId, keyId, DAY, now) >= limits.rpd;
const tpdExhausted = limits.tpd !== null && tokenCount(platform, modelId, keyId, DAY, now) >= limits.tpd;
const base = (rpdExhausted || tpdExhausted)
? getNextCooldownDuration(platform, modelId, keyId)
: TRANSIENT_COOLDOWN_MS;
// Honor an upstream Retry-After as a floor: never bench shorter than our own
// heuristic, but extend (capped at a day) when the provider explicitly asks
// to wait longer than we otherwise would.
if (retryAfterMs != null && retryAfterMs > base)
return Math.min(retryAfterMs, DAY);
return base;
}
function persistedCooldownExpiry(platform, modelId, keyId) {
return withDb(db => {
const row = db.prepare(`
SELECT expires_at_ms
FROM rate_limit_cooldowns
WHERE platform = ?
AND model_id = ?
AND key_id = ?
`).get(platform, modelId, keyId);
return row?.expires_at_ms ?? null;
});
}
function persistCooldown(platform, modelId, keyId, expiresAtMs) {
withDb(db => {
db.prepare(`
INSERT INTO rate_limit_cooldowns (platform, model_id, key_id, expires_at_ms)
VALUES (?, ?, ?, ?)
ON CONFLICT(platform, model_id, key_id)
DO UPDATE SET expires_at_ms = excluded.expires_at_ms
`).run(platform, modelId, keyId, expiresAtMs);
});
}
function clearPersistedCooldown(platform, modelId, keyId) {
withDb(db => {
db.prepare(`
DELETE FROM rate_limit_cooldowns
WHERE platform = ?
AND model_id = ?
AND key_id = ?
`).run(platform, modelId, keyId);
});
}
export function setCooldown(platform, modelId, keyId, durationMs = 60_000) {
const key = `${platform}:${modelId}:${keyId}:cooldown`;
const expiresAtMs = Date.now() + durationMs;
cooldowns.set(key, expiresAtMs);
persistCooldown(platform, modelId, keyId, expiresAtMs);
}
export function isOnCooldown(platform, modelId, keyId) {
const key = `${platform}:${modelId}:${keyId}:cooldown`;
const now = Date.now();
const persistedExpiry = persistedCooldownExpiry(platform, modelId, keyId);
if (persistedExpiry !== undefined && persistedExpiry !== null) {
if (now > persistedExpiry) {
cooldowns.delete(key);
clearPersistedCooldown(platform, modelId, keyId);
return false;
}
cooldowns.set(key, persistedExpiry);
return true;
}
const expiry = cooldowns.get(key);
if (!expiry)
return false;
if (now > expiry) {
cooldowns.delete(key);
return false;
}
return true;
}
export function getRateLimitStatus(platform, modelId, keyId, limits) {
const now = Date.now();
return {
rpm: { used: requestCount(platform, modelId, keyId, MINUTE, now), limit: limits.rpm },
rpd: { used: requestCount(platform, modelId, keyId, DAY, now), limit: limits.rpd },
tpm: { used: tokenCount(platform, modelId, keyId, MINUTE, now), limit: limits.tpm },
};
}
//# sourceMappingURL=ratelimit.js.map