| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { extractApiKey } from "@/sse/services/auth"; |
| import { getApiKeyMetadata, isModelAllowedForKey } from "@/lib/localDb"; |
| import { checkBudget } from "@/domain/costRules"; |
| import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; |
| import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; |
| import * as log from "@/sse/utils/logger"; |
|
|
| interface AccessSchedule { |
| enabled: boolean; |
| from: string; |
| until: string; |
| days: number[]; |
| tz: string; |
| } |
|
|
| |
| export interface ApiKeyMetadata { |
| id: string; |
| name?: string; |
| allowedModels?: string[]; |
| allowedConnections?: string[]; |
| noLog?: boolean; |
| autoResolve?: boolean; |
| budget?: number; |
| usedBudget?: number; |
| isActive?: boolean; |
| accessSchedule?: AccessSchedule | null; |
| maxRequestsPerDay?: number | null; |
| maxRequestsPerMinute?: number | null; |
| maxSessions?: number | null; |
| } |
|
|
| |
| |
| |
| |
| |
| function isWithinSchedule(schedule: AccessSchedule): boolean { |
| if (!schedule.enabled) return true; |
|
|
| const now = new Date(); |
|
|
| |
| let localTimeStr: string; |
| try { |
| localTimeStr = new Intl.DateTimeFormat("en-US", { |
| timeZone: schedule.tz, |
| hour: "2-digit", |
| minute: "2-digit", |
| hour12: false, |
| }).format(now); |
| } catch { |
| |
| return true; |
| } |
|
|
| |
| const normalizedTime = localTimeStr.replace(/^24:/, "00:"); |
| const [localHour, localMin] = normalizedTime.split(":").map(Number); |
| const localMinutes = localHour * 60 + localMin; |
|
|
| |
| let localDayStr: string; |
| try { |
| localDayStr = new Intl.DateTimeFormat("en-US", { |
| timeZone: schedule.tz, |
| weekday: "short", |
| }).format(now); |
| } catch { |
| return true; |
| } |
|
|
| const dayMap: Record<string, number> = { |
| Sun: 0, |
| Mon: 1, |
| Tue: 2, |
| Wed: 3, |
| Thu: 4, |
| Fri: 5, |
| Sat: 6, |
| }; |
| const localDay = dayMap[localDayStr] ?? now.getDay(); |
|
|
| if (!schedule.days.includes(localDay)) return false; |
|
|
| const [fromHour, fromMin] = schedule.from.split(":").map(Number); |
| const [untilHour, untilMin] = schedule.until.split(":").map(Number); |
| const fromMinutes = fromHour * 60 + fromMin; |
| const untilMinutes = untilHour * 60 + untilMin; |
|
|
| |
| if (untilMinutes < fromMinutes) { |
| return localMinutes >= fromMinutes || localMinutes < untilMinutes; |
| } |
|
|
| return localMinutes >= fromMinutes && localMinutes < untilMinutes; |
| } |
|
|
| |
|
|
| |
| const _requestTimestamps = new Map<string, number[]>(); |
| const REQUEST_COUNTER_MAX_KEYS = 5000; |
| const REQUEST_DAY_MS = 24 * 60 * 60 * 1000; |
| const REQUEST_MINUTE_MS = 60 * 1000; |
|
|
| |
| function checkRequestCountLimits( |
| apiKeyId: string, |
| maxPerDay: number | null | undefined, |
| maxPerMinute: number | null | undefined |
| ): string | null { |
| if (!maxPerDay && !maxPerMinute) return null; |
|
|
| const now = Date.now(); |
|
|
| |
| let timestamps = _requestTimestamps.get(apiKeyId); |
| if (!timestamps) { |
| timestamps = []; |
| _requestTimestamps.set(apiKeyId, timestamps); |
| |
| if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) { |
| const firstKey = _requestTimestamps.keys().next().value; |
| if (firstKey) _requestTimestamps.delete(firstKey); |
| } |
| } |
|
|
| |
| const dayAgo = now - REQUEST_DAY_MS; |
| while (timestamps.length > 0 && timestamps[0] < dayAgo) { |
| timestamps.shift(); |
| } |
|
|
| |
| if (maxPerMinute && maxPerMinute > 0) { |
| const minuteAgo = now - REQUEST_MINUTE_MS; |
| const recentCount = timestamps.filter((t) => t >= minuteAgo).length; |
| if (recentCount >= maxPerMinute) { |
| return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`; |
| } |
| } |
|
|
| |
| if (maxPerDay && maxPerDay > 0) { |
| if (timestamps.length >= maxPerDay) { |
| return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil( |
| (timestamps[0] + REQUEST_DAY_MS - now) / 60000 |
| )} minutes.`; |
| } |
| } |
|
|
| |
| timestamps.push(now); |
| return null; |
| } |
|
|
| export interface ApiKeyPolicyResult { |
| |
| apiKey: string | null; |
| |
| apiKeyInfo: ApiKeyMetadata | null; |
| |
| rejection: Response | null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function enforceApiKeyPolicy( |
| request: Request, |
| modelStr: string | null |
| ): Promise<ApiKeyPolicyResult> { |
| const apiKey = extractApiKey(request); |
|
|
| |
| if (!apiKey) { |
| return { apiKey: null, apiKeyInfo: null, rejection: null }; |
| } |
|
|
| |
| let apiKeyInfo: ApiKeyMetadata | null = null; |
| try { |
| apiKeyInfo = await getApiKeyMetadata(apiKey); |
| } catch (error) { |
| |
| log.error("API_POLICY", "Failed to fetch API key metadata. Request blocked.", { error }); |
| return { |
| apiKey, |
| apiKeyInfo: null, |
| rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key policy unavailable"), |
| }; |
| } |
|
|
| |
| if (!apiKeyInfo) { |
| return { apiKey, apiKeyInfo: null, rejection: null }; |
| } |
|
|
| |
| if (apiKeyInfo.isActive === false) { |
| return { |
| apiKey, |
| apiKeyInfo, |
| rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"), |
| }; |
| } |
|
|
| |
| if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) { |
| if (!isWithinSchedule(apiKeyInfo.accessSchedule)) { |
| const { from, until, tz } = apiKeyInfo.accessSchedule; |
| return { |
| apiKey, |
| apiKeyInfo, |
| rejection: errorResponse( |
| HTTP_STATUS.FORBIDDEN, |
| `Access denied outside allowed hours (${from}β${until} ${tz})` |
| ), |
| }; |
| } |
| } |
|
|
| |
| if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) { |
| const allowed = await isModelAllowedForKey(apiKey, modelStr); |
| if (!allowed) { |
| return { |
| apiKey, |
| apiKeyInfo, |
| rejection: errorResponse( |
| HTTP_STATUS.FORBIDDEN, |
| `Model "${modelStr}" is not allowed for this API key` |
| ), |
| }; |
| } |
| } |
|
|
| |
| if (apiKeyInfo.id) { |
| try { |
| const budgetOk = checkBudget(apiKeyInfo.id); |
| if (!budgetOk.allowed) { |
| return { |
| apiKey, |
| apiKeyInfo, |
| rejection: errorResponse( |
| HTTP_STATUS.RATE_LIMITED, |
| budgetOk.reason || "Budget limit exceeded" |
| ), |
| }; |
| } |
| } catch (error) { |
| |
| log.error("API_POLICY", "Budget check failed. Request blocked.", { error }); |
| return { |
| apiKey, |
| apiKeyInfo, |
| rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Budget policy unavailable"), |
| }; |
| } |
| } |
|
|
| |
| if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) { |
| const limitError = checkRequestCountLimits( |
| apiKeyInfo.id, |
| apiKeyInfo.maxRequestsPerDay, |
| apiKeyInfo.maxRequestsPerMinute |
| ); |
| if (limitError) { |
| return { |
| apiKey, |
| apiKeyInfo, |
| rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError), |
| }; |
| } |
| } |
|
|
| return { apiKey, apiKeyInfo, rejection: null }; |
| } |
|
|