| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { extractApiKey } from "@/sse/services/auth";
|
| import { getApiKeyMetadata, getComboByName, isModelAllowedForKey } from "@/lib/localDb";
|
| import { resolveComboForModel } from "@/lib/db/modelComboMappings";
|
| import { checkBudget } from "@/domain/costRules";
|
| import { checkTokenLimits } from "@omniroute/open-sse/services/tokenLimitCounter.ts";
|
| import { errorResponse, buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
|
| import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
| import * as log from "@/sse/utils/logger";
|
| import { checkRateLimit, RateLimitRule } from "./rateLimiter";
|
| import { resolveEndpointCategory } from "@/shared/constants/endpointCategories";
|
| import { resolveQuotaKeyScope } from "@/lib/quota/quotaKey";
|
| import { isQuotaModelName, parseQuotaModelName } from "@/lib/quota/quotaModelNaming";
|
|
|
|
|
|
|
|
|
|
|
| export const DEFAULT_RATE_LIMITS: RateLimitRule[] = [];
|
|
|
| const LEGACY_DEFAULT_RATE_LIMIT_PER_DAY = 1000;
|
|
|
| export function buildDefaultRateLimits(rawValue?: string): RateLimitRule[] {
|
| const normalized = rawValue?.trim();
|
| if (normalized === undefined || normalized === "") return [];
|
|
|
| const limitPerDay = /^\d+$/.test(normalized)
|
| ? Number(normalized)
|
| : LEGACY_DEFAULT_RATE_LIMIT_PER_DAY;
|
|
|
| if (limitPerDay === 0) return [];
|
|
|
| return [
|
| { limit: limitPerDay, window: 86400 },
|
| { limit: limitPerDay * 5, window: 604800 },
|
| { limit: limitPerDay * 20, window: 2592000 },
|
| ];
|
| }
|
|
|
| const ENV_DEFAULT_RATE_LIMITS: RateLimitRule[] = buildDefaultRateLimits(
|
| process.env.DEFAULT_RATE_LIMIT_PER_DAY
|
| );
|
|
|
| interface AccessSchedule {
|
| enabled: boolean;
|
| from: string;
|
| until: string;
|
| days: number[];
|
| tz: string;
|
| }
|
|
|
|
|
| export interface ApiKeyMetadata {
|
| id: string;
|
| name?: string;
|
| allowedModels?: string[];
|
| allowedCombos?: string[];
|
| allowedConnections?: string[];
|
| allowedQuotas?: string[];
|
| noLog?: boolean;
|
| autoResolve?: boolean;
|
| budget?: number;
|
| usedBudget?: number;
|
| isActive?: boolean;
|
| isBanned?: boolean;
|
| expiresAt?: string | null;
|
| accessSchedule?: AccessSchedule | null;
|
| maxRequestsPerDay?: number | null;
|
| maxRequestsPerMinute?: number | null;
|
| throttleDelayMs?: number | null;
|
| maxSessions?: number | null;
|
| rateLimits?: RateLimitRule[] | null;
|
| allowedEndpoints?: string[];
|
| disableNonPublicModels?: boolean;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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;
|
| }
|
|
|
|
|
|
|
| function delay(ms: number): Promise<void> {
|
| return new Promise((resolve) => setTimeout(resolve, ms));
|
| }
|
|
|
| function normalizeComboAccessName(value: unknown): string | null {
|
| if (typeof value !== "string") return null;
|
| const trimmed = value.trim();
|
| if (!trimmed) return null;
|
| return trimmed.startsWith("combo/") ? trimmed.slice(6).trim() || trimmed : trimmed;
|
| }
|
|
|
| function matchesComboAccessRule(comboName: string, requestedModel: string, rule: string): boolean {
|
| const normalizedRule = normalizeComboAccessName(rule);
|
| if (!normalizedRule) return false;
|
| return (
|
| normalizedRule === comboName ||
|
| rule === requestedModel ||
|
| `combo/${normalizedRule}` === requestedModel
|
| );
|
| }
|
|
|
| async function resolveRequestedComboName(modelStr: string): Promise<string | null> {
|
| const exact = await getComboByName(modelStr);
|
| if (exact && typeof exact.name === "string") return exact.name;
|
|
|
| if (modelStr.startsWith("combo/")) {
|
| const withoutPrefix = modelStr.slice(6);
|
| const prefixed = await getComboByName(withoutPrefix);
|
| if (prefixed && typeof prefixed.name === "string") return prefixed.name;
|
| }
|
|
|
| const mapped = await resolveComboForModel(modelStr);
|
| const mappedName = normalizeComboAccessName(mapped?.name);
|
| return mappedName;
|
| }
|
|
|
| async function isComboAllowedForKey(
|
| allowedCombos: string[],
|
| modelStr: string
|
| ): Promise<{ allowed: boolean; comboName: string | null }> {
|
| const comboName = await resolveRequestedComboName(modelStr);
|
| if (!comboName) return { allowed: true, comboName: null };
|
|
|
| const allowed = allowedCombos.some((rule) => matchesComboAccessRule(comboName, modelStr, rule));
|
| return { allowed, comboName };
|
| }
|
|
|
| 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.isBanned === true) {
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.FORBIDDEN,
|
| "This API key is banned due to policy violations"
|
| ),
|
| };
|
| }
|
|
|
|
|
| if (apiKeyInfo.expiresAt) {
|
| const expiry = new Date(apiKeyInfo.expiresAt).getTime();
|
| if (Date.now() > expiry) {
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key has expired"),
|
| };
|
| }
|
| }
|
|
|
|
|
| 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 (apiKeyInfo.allowedEndpoints && apiKeyInfo.allowedEndpoints.length > 0) {
|
| try {
|
| const url = new URL(request.url);
|
| const category = resolveEndpointCategory(url.pathname);
|
| if (category && !apiKeyInfo.allowedEndpoints.includes(category)) {
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.FORBIDDEN,
|
| `Endpoint category "${category}" is not allowed for this API key`
|
| ),
|
| };
|
| }
|
| } catch {
|
|
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (
|
| modelStr &&
|
| isQuotaModelName(modelStr) &&
|
| !(Array.isArray(apiKeyInfo.allowedQuotas) && apiKeyInfo.allowedQuotas.length > 0)
|
| ) {
|
| const notAllocatedBody = buildErrorBody(
|
| HTTP_STATUS.FORBIDDEN,
|
| `Model "${modelStr}" requires a quota-pool allocation; this API key is not allocated to any quota pool`
|
| );
|
| notAllocatedBody.error.code = "QUOTA_NOT_ALLOCATED";
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: new Response(JSON.stringify(notAllocatedBody), {
|
| status: HTTP_STATUS.FORBIDDEN,
|
| headers: { "Content-Type": "application/json" },
|
| }),
|
| };
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (modelStr && apiKeyInfo.allowedQuotas && apiKeyInfo.allowedQuotas.length > 0) {
|
| try {
|
| const scope = await resolveQuotaKeyScope(apiKeyInfo.allowedQuotas);
|
| let quotaRejectionMsg: string | null = null;
|
|
|
| if (isQuotaModelName(modelStr)) {
|
|
|
| const parsed = parseQuotaModelName(modelStr);
|
| const allowed =
|
| parsed !== null &&
|
| scope.poolSlugs.length > 0 &&
|
| scope.poolSlugs.includes(parsed.groupSlug) &&
|
| scope.providers.includes(parsed.provider);
|
| if (!allowed) {
|
| quotaRejectionMsg = `Model "${modelStr}" is not in this key's quota pools`;
|
| }
|
| } else {
|
|
|
| quotaRejectionMsg = `This quota-exclusive API key may only use quotaShared-* models`;
|
| }
|
|
|
| if (quotaRejectionMsg !== null) {
|
| const quotaBody = buildErrorBody(HTTP_STATUS.FORBIDDEN, quotaRejectionMsg);
|
| quotaBody.error.code = "QUOTA_ONLY";
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: new Response(JSON.stringify(quotaBody), {
|
| status: HTTP_STATUS.FORBIDDEN,
|
| headers: { "Content-Type": "application/json" },
|
| }),
|
| };
|
| }
|
|
|
|
|
| } catch (error) {
|
| log.error("API_POLICY", "Quota scope check failed. Request blocked.", { error });
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.SERVICE_UNAVAILABLE,
|
| "API key quota policy unavailable"
|
| ),
|
| };
|
| }
|
| }
|
|
|
|
|
| let requestedComboName: string | null = null;
|
| const isQuotaExclusive =
|
| Boolean(apiKeyInfo.allowedQuotas) && (apiKeyInfo.allowedQuotas as string[]).length > 0;
|
| if (!isQuotaExclusive && modelStr && apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) {
|
| try {
|
| const comboAccess = await isComboAllowedForKey(apiKeyInfo.allowedCombos, modelStr);
|
| requestedComboName = comboAccess.comboName;
|
| if (!comboAccess.allowed) {
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.FORBIDDEN,
|
| `Combo "${comboAccess.comboName || modelStr}" is not allowed for this API key`
|
| ),
|
| };
|
| }
|
| } catch (error) {
|
| log.error("API_POLICY", "Combo access check failed. Request blocked.", { error });
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.SERVICE_UNAVAILABLE,
|
| "API key combo policy unavailable"
|
| ),
|
| };
|
| }
|
| }
|
|
|
| const hasModelRestrictions =
|
| !isQuotaExclusive &&
|
| ((apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) ||
|
| (apiKeyInfo as { disableNonPublicModels?: boolean }).disableNonPublicModels === true);
|
|
|
| if (!requestedComboName && modelStr && hasModelRestrictions) {
|
|
|
|
|
| if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
|
| requestedComboName = modelStr;
|
| } else {
|
| try {
|
| requestedComboName = await resolveRequestedComboName(modelStr);
|
| } catch {
|
| requestedComboName = null;
|
| }
|
| }
|
| }
|
|
|
| if (modelStr && !requestedComboName && hasModelRestrictions) {
|
| 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) {
|
| try {
|
| const breach = checkTokenLimits(apiKeyInfo.id, undefined, modelStr ?? undefined);
|
| if (breach) {
|
| const scopeLabel =
|
| breach.scopeType === "global"
|
| ? "account"
|
| : `${breach.scopeType} "${breach.scopeValue}"`;
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.RATE_LIMITED,
|
| `Token limit exceeded for ${scopeLabel}: ${breach.tokensUsed}/${breach.limitValue} tokens used in the current window. Please try again later.`
|
| ),
|
| };
|
| }
|
| } catch (error) {
|
|
|
|
|
| log.error("API_POLICY", "Token limit check failed. Request blocked.", { error });
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.SERVICE_UNAVAILABLE,
|
| "Token limit policy unavailable"
|
| ),
|
| };
|
| }
|
| }
|
|
|
|
|
| if (apiKeyInfo.id) {
|
| const hasCustomRateLimits = Boolean(apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0);
|
| const rulesToApply = hasCustomRateLimits
|
| ? [...(apiKeyInfo.rateLimits as RateLimitRule[])]
|
| : [...DEFAULT_RATE_LIMITS, ...ENV_DEFAULT_RATE_LIMITS];
|
|
|
|
|
| if (!hasCustomRateLimits) {
|
| if (apiKeyInfo.maxRequestsPerDay) {
|
| rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerDay, window: 86400 });
|
| }
|
| if (apiKeyInfo.maxRequestsPerMinute) {
|
| rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerMinute, window: 60 });
|
| }
|
| }
|
|
|
| if (rulesToApply.length > 0) {
|
| const rateLimitResult = await checkRateLimit(apiKeyInfo.id, rulesToApply);
|
| if (!rateLimitResult.allowed) {
|
| const failedWindowStr = rateLimitResult.failedWindow
|
| ? ` (${rateLimitResult.failedWindow}s window)`
|
| : "";
|
| return {
|
| apiKey,
|
| apiKeyInfo,
|
| rejection: errorResponse(
|
| HTTP_STATUS.RATE_LIMITED,
|
| `Request limit exceeded${failedWindowStr}. Please try again later.`
|
| ),
|
| };
|
| }
|
| }
|
| }
|
|
|
|
|
| if (apiKeyInfo.throttleDelayMs && apiKeyInfo.throttleDelayMs > 0) {
|
| await delay(Math.min(apiKeyInfo.throttleDelayMs, 300_000));
|
| }
|
|
|
| return { apiKey, apiKeyInfo, rejection: null };
|
| }
|
|
|