mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
Add reasoning-based model and effort routing (#7607)
* feat(routing): add reasoning-based model and effort routing * refactor(routing): modularize reasoning and auto-routing pipeline * fix(routing): remove redundant DB re-export and prevent SQL scan false positives * fix(routing): resolve reasoning routing review blockers * fix(i18n): keep release ranking fallbacks outside reasoning * fix(db): renumber reasoning-routing migration past release tip (124→125) 124_generic_session_affinity_ttl.sql (#7274) has since landed on release/v3.8.49 at version 124, colliding with this PR's own 124_reasoning_routing_rules.sql. Renumbers to 125 (the next free slot past the current release tip) and updates the one filename reference in docs/routing/REASONING_ROUTING.md. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(db): renumber reasoning-routing migration 125→126 (slot taken by #7360) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(api): compact temp-path decls in exportAll GET (complexity-ratchet lines budget) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(api): single-statement auth guard in exportAll GET (function under 80-line cap) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -253,6 +253,122 @@ async function isComboAllowedForKey(
|
||||
return { allowed, comboName };
|
||||
}
|
||||
|
||||
function quotaPolicyResponse(message: string, code: string): Response {
|
||||
const body = buildErrorBody(HTTP_STATUS.FORBIDDEN, message);
|
||||
body.error.code = code;
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: HTTP_STATUS.FORBIDDEN,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function validateQuotaRoutingTarget(
|
||||
modelStr: string,
|
||||
allowedQuotas: string[]
|
||||
): Promise<Response | null> {
|
||||
if (isQuotaModelName(modelStr) && allowedQuotas.length === 0) {
|
||||
return quotaPolicyResponse(
|
||||
`Model "${modelStr}" requires a quota-pool allocation; this API key is not allocated to any quota pool`,
|
||||
"QUOTA_NOT_ALLOCATED"
|
||||
);
|
||||
}
|
||||
if (allowedQuotas.length === 0) return null;
|
||||
|
||||
try {
|
||||
const scope = await resolveQuotaKeyScope(allowedQuotas);
|
||||
const parsed = isQuotaModelName(modelStr) ? parseQuotaModelName(modelStr) : null;
|
||||
const allowed =
|
||||
parsed !== null &&
|
||||
scope.poolSlugs.includes(parsed.groupSlug) &&
|
||||
scope.providers.includes(parsed.provider);
|
||||
if (allowed) return null;
|
||||
return quotaPolicyResponse(
|
||||
isQuotaModelName(modelStr)
|
||||
? `Model "${modelStr}" is not in this key's quota pools`
|
||||
: "This quota-exclusive API key may only use quotaShared-* models",
|
||||
"QUOTA_ONLY"
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("API_POLICY", "Routing target quota check failed. Request blocked.", { error });
|
||||
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key quota policy unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateStandardRoutingTarget(
|
||||
request: Request,
|
||||
apiKey: string,
|
||||
apiKeyInfo: ApiKeyMetadata,
|
||||
modelStr: string
|
||||
): Promise<Response | null> {
|
||||
let requestedComboName: string | null = null;
|
||||
if (apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) {
|
||||
try {
|
||||
const comboAccess = await isComboAllowedForKey(apiKeyInfo.allowedCombos, modelStr);
|
||||
requestedComboName = comboAccess.comboName;
|
||||
if (!comboAccess.allowed) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Combo "${comboAccess.comboName || modelStr}" is not allowed for this API key`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("API_POLICY", "Routing target combo check failed. Request blocked.", { error });
|
||||
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key combo policy unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
const hasModelRestrictions =
|
||||
(apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) ||
|
||||
apiKeyInfo.disableNonPublicModels === true;
|
||||
if (!requestedComboName && hasModelRestrictions && modelStr.startsWith("auto/")) {
|
||||
requestedComboName = modelStr;
|
||||
}
|
||||
if (!requestedComboName && hasModelRestrictions) {
|
||||
try {
|
||||
requestedComboName = await resolveRequestedComboName(modelStr);
|
||||
} catch {
|
||||
requestedComboName = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!requestedComboName &&
|
||||
hasModelRestrictions &&
|
||||
!(await isModelAllowedForKey(apiKey, modelStr))
|
||||
) {
|
||||
return policyErrorResponse(
|
||||
request,
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Model "${modelStr}" is not allowed for this API key`,
|
||||
`Model "${modelStr}" is not enabled or quota is insufficient. Choose another allowed model.`,
|
||||
"invalid_request_error",
|
||||
HTTP_STATUS.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate only the model/combo authorization of a routing target.
|
||||
*
|
||||
* The full request policy has already run before routing. Calling it again for a
|
||||
* policy-generated target would charge request limits twice and apply throttling
|
||||
* twice. This narrower check proves that routing did not widen the key's access
|
||||
* without consuming any budget, token-limit, or rate-limit state.
|
||||
*/
|
||||
export async function validateApiKeyRoutingTarget(
|
||||
request: Request,
|
||||
apiKey: string | null,
|
||||
apiKeyInfo: ApiKeyMetadata | null,
|
||||
modelStr: string | null
|
||||
): Promise<Response | null> {
|
||||
if (!apiKey || !apiKeyInfo || !modelStr) return null;
|
||||
|
||||
const allowedQuotas = Array.isArray(apiKeyInfo.allowedQuotas) ? apiKeyInfo.allowedQuotas : [];
|
||||
const quotaRejection = await validateQuotaRoutingTarget(modelStr, allowedQuotas);
|
||||
if (quotaRejection || allowedQuotas.length > 0) return quotaRejection;
|
||||
return validateStandardRoutingTarget(request, apiKey, apiKeyInfo, modelStr);
|
||||
}
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
/** API key string (null if no key provided) */
|
||||
apiKey: string | null;
|
||||
@@ -306,6 +422,222 @@ export async function resolvePlaygroundTestKey(request: Request): Promise<string
|
||||
}
|
||||
}
|
||||
|
||||
type PolicyContext = {
|
||||
request: Request;
|
||||
apiKey: string;
|
||||
apiKeyInfo: ApiKeyMetadata;
|
||||
modelStr: string | null;
|
||||
};
|
||||
|
||||
function validateKeyStatus(context: PolicyContext): Response | null {
|
||||
const { apiKeyInfo } = context;
|
||||
if (apiKeyInfo.isActive === false) {
|
||||
return errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled");
|
||||
}
|
||||
if (apiKeyInfo.isBanned === true) {
|
||||
return errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is banned due to policy violations");
|
||||
}
|
||||
if (apiKeyInfo.expiresAt && Date.now() > new Date(apiKeyInfo.expiresAt).getTime()) {
|
||||
return errorResponse(HTTP_STATUS.FORBIDDEN, "This API key has expired");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validateKeyScheduleAndUsage(context: PolicyContext): Promise<Response | null> {
|
||||
const { request, apiKey, apiKeyInfo } = context;
|
||||
if (apiKeyInfo.accessSchedule?.enabled && !isWithinSchedule(apiKeyInfo.accessSchedule)) {
|
||||
const { from, until, tz } = apiKeyInfo.accessSchedule;
|
||||
return errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Access denied outside allowed hours (${from}–${until} ${tz})`
|
||||
);
|
||||
}
|
||||
if (apiKeyInfo.usageLimitEnabled !== true) return null;
|
||||
|
||||
try {
|
||||
const rejection = await buildApiKeyUsageLimitPolicyRejection(request, {
|
||||
id: apiKeyInfo.id,
|
||||
usageLimitEnabled: apiKeyInfo.usageLimitEnabled,
|
||||
dailyUsageLimitUsd: apiKeyInfo.dailyUsageLimitUsd,
|
||||
weeklyUsageLimitUsd: apiKeyInfo.weeklyUsageLimitUsd,
|
||||
});
|
||||
return rejection;
|
||||
} catch (error) {
|
||||
log.error("API_POLICY", "API key USD usage limit check failed. Request blocked.", { error });
|
||||
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key usage limit unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function validateEndpointAccess(context: PolicyContext): Response | null {
|
||||
const { request, apiKeyInfo } = context;
|
||||
if (!apiKeyInfo.allowedEndpoints?.length) return null;
|
||||
try {
|
||||
const category = resolveEndpointCategory(new URL(request.url).pathname);
|
||||
if (category && !apiKeyInfo.allowedEndpoints.includes(category)) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Endpoint category "${category}" is not allowed for this API key`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// URL parse failure — fail open, let other checks decide.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validateQuotaAccess(context: PolicyContext): Promise<Response | null> {
|
||||
const { apiKey, apiKeyInfo, modelStr } = context;
|
||||
if (!modelStr) return null;
|
||||
const allowedQuotas = Array.isArray(apiKeyInfo.allowedQuotas) ? apiKeyInfo.allowedQuotas : [];
|
||||
if (isQuotaModelName(modelStr) && allowedQuotas.length === 0) {
|
||||
return quotaPolicyResponse(
|
||||
`Model "${modelStr}" requires a quota-pool allocation; this API key is not allocated to any quota pool`,
|
||||
"QUOTA_NOT_ALLOCATED"
|
||||
);
|
||||
}
|
||||
if (!allowedQuotas.length) return null;
|
||||
|
||||
try {
|
||||
const scope = await resolveQuotaKeyScope(allowedQuotas);
|
||||
const parsed = isQuotaModelName(modelStr) ? parseQuotaModelName(modelStr) : null;
|
||||
const allowed =
|
||||
parsed !== null &&
|
||||
scope.poolSlugs.length > 0 &&
|
||||
scope.poolSlugs.includes(parsed.groupSlug) &&
|
||||
scope.providers.includes(parsed.provider);
|
||||
if (allowed) return null;
|
||||
const message = isQuotaModelName(modelStr)
|
||||
? `Model "${modelStr}" is not in this key's quota pools`
|
||||
: "This quota-exclusive API key may only use quotaShared-* models";
|
||||
return quotaPolicyResponse(message, "QUOTA_ONLY");
|
||||
} catch (error) {
|
||||
log.error("API_POLICY", "Quota scope check failed. Request blocked.", { error });
|
||||
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key quota policy unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateModelAccess(context: PolicyContext): Promise<Response | null> {
|
||||
const { request, apiKey, apiKeyInfo, modelStr } = context;
|
||||
if (!modelStr || apiKeyInfo.allowedQuotas?.length) return null;
|
||||
const comboAccess = await validateComboAccess(apiKeyInfo.allowedCombos, modelStr);
|
||||
if (comboAccess.rejection) return comboAccess.rejection;
|
||||
let requestedComboName = comboAccess.comboName;
|
||||
|
||||
const hasModelRestrictions =
|
||||
Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true;
|
||||
if (!requestedComboName && hasModelRestrictions) {
|
||||
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
|
||||
requestedComboName = modelStr;
|
||||
} else {
|
||||
try {
|
||||
requestedComboName = await resolveRequestedComboName(modelStr);
|
||||
} catch {
|
||||
requestedComboName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requestedComboName || !hasModelRestrictions) return null;
|
||||
if (await isModelAllowedForKey(apiKey, modelStr)) return null;
|
||||
return policyErrorResponse(
|
||||
request,
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Model "${modelStr}" is not allowed for this API key`,
|
||||
`Model "${modelStr}" is not enabled or quota is insufficient. Choose another allowed model.`,
|
||||
"invalid_request_error",
|
||||
HTTP_STATUS.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
async function validateComboAccess(
|
||||
allowedCombos: string[] | undefined,
|
||||
modelStr: string
|
||||
): Promise<{ comboName: string | null; rejection: Response | null }> {
|
||||
if (!allowedCombos?.length) return { comboName: null, rejection: null };
|
||||
try {
|
||||
const comboAccess = await isComboAllowedForKey(allowedCombos, modelStr);
|
||||
if (comboAccess.allowed) return { comboName: comboAccess.comboName, rejection: null };
|
||||
return {
|
||||
comboName: comboAccess.comboName,
|
||||
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 {
|
||||
comboName: null,
|
||||
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key combo policy unavailable"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateBudget(context: PolicyContext): Response | null {
|
||||
const { apiKeyInfo } = context;
|
||||
if (!apiKeyInfo.id) return null;
|
||||
try {
|
||||
const budgetOk = checkBudget(apiKeyInfo.id);
|
||||
return budgetOk.allowed
|
||||
? null
|
||||
: errorResponse(HTTP_STATUS.RATE_LIMITED, budgetOk.reason || "Budget limit exceeded");
|
||||
} catch (error) {
|
||||
log.error("API_POLICY", "Budget check failed. Request blocked.", { error });
|
||||
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Budget policy unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function validateTokenLimit(context: PolicyContext): Response | null {
|
||||
const { apiKeyInfo, modelStr } = context;
|
||||
if (!apiKeyInfo.id) return null;
|
||||
try {
|
||||
const breach = checkTokenLimits(apiKeyInfo.id, undefined, modelStr ?? undefined);
|
||||
if (!breach) return null;
|
||||
const scopeLabel =
|
||||
breach.scopeType === "global" ? "account" : `${breach.scopeType} "${breach.scopeValue}"`;
|
||||
return 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 errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Token limit policy unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function buildRateLimitRules(apiKeyInfo: ApiKeyMetadata): RateLimitRule[] {
|
||||
const custom = apiKeyInfo.rateLimits?.length;
|
||||
const rules = custom
|
||||
? [...(apiKeyInfo.rateLimits as RateLimitRule[])]
|
||||
: [...DEFAULT_RATE_LIMITS, ...ENV_DEFAULT_RATE_LIMITS];
|
||||
if (!custom) {
|
||||
if (apiKeyInfo.maxRequestsPerDay)
|
||||
rules.push({ limit: apiKeyInfo.maxRequestsPerDay, window: 86400 });
|
||||
if (apiKeyInfo.maxRequestsPerMinute)
|
||||
rules.push({ limit: apiKeyInfo.maxRequestsPerMinute, window: 60 });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
async function validateRateLimitAndThrottle(context: PolicyContext): Promise<Response | null> {
|
||||
const { apiKeyInfo } = context;
|
||||
if (!apiKeyInfo.id) return null;
|
||||
const rules = buildRateLimitRules(apiKeyInfo);
|
||||
if (rules.length) {
|
||||
const result = await checkRateLimit(apiKeyInfo.id, rules);
|
||||
if (!result.allowed) {
|
||||
const window = result.failedWindow ? ` (${result.failedWindow}s window)` : "";
|
||||
return errorResponse(
|
||||
HTTP_STATUS.RATE_LIMITED,
|
||||
`Request limit exceeded${window}. Please try again later.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (apiKeyInfo.throttleDelayMs && apiKeyInfo.throttleDelayMs > 0) {
|
||||
await delay(Math.min(apiKeyInfo.throttleDelayMs, 300_000));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function enforceApiKeyPolicy(
|
||||
request: Request,
|
||||
modelStr: string | null
|
||||
@@ -338,343 +670,25 @@ export async function enforceApiKeyPolicy(
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: is_active / is_banned ──
|
||||
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"
|
||||
),
|
||||
};
|
||||
}
|
||||
const context = { request, apiKey, apiKeyInfo, modelStr };
|
||||
const statusRejection = validateKeyStatus(context);
|
||||
if (statusRejection) return { apiKey, apiKeyInfo, rejection: statusRejection };
|
||||
const scheduleRejection = await validateKeyScheduleAndUsage(context);
|
||||
if (scheduleRejection) return { apiKey, apiKeyInfo, rejection: scheduleRejection };
|
||||
const endpointRejection = validateEndpointAccess(context);
|
||||
if (endpointRejection) return { apiKey, apiKeyInfo, rejection: endpointRejection };
|
||||
|
||||
// ── Check 1.5: expires_at ──
|
||||
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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
const quotaRejection = await validateQuotaAccess(context);
|
||||
if (quotaRejection) return { apiKey, apiKeyInfo, rejection: quotaRejection };
|
||||
const modelRejection = await validateModelAccess(context);
|
||||
if (modelRejection) return { apiKey, apiKeyInfo, rejection: modelRejection };
|
||||
|
||||
// ── Check 2: access_schedule — time-based access window ──
|
||||
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})`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2.1: per-key USD fair usage cap ──
|
||||
if (apiKeyInfo.usageLimitEnabled === true) {
|
||||
try {
|
||||
const usageLimitRejection = await buildApiKeyUsageLimitPolicyRejection(request, {
|
||||
id: apiKeyInfo.id,
|
||||
usageLimitEnabled: apiKeyInfo.usageLimitEnabled,
|
||||
dailyUsageLimitUsd: apiKeyInfo.dailyUsageLimitUsd,
|
||||
weeklyUsageLimitUsd: apiKeyInfo.weeklyUsageLimitUsd,
|
||||
});
|
||||
if (usageLimitRejection) {
|
||||
return { apiKey, apiKeyInfo, rejection: usageLimitRejection };
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("API_POLICY", "API key USD usage limit check failed. Request blocked.", { error });
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
"API key usage limit unavailable"
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2.5: Endpoint restriction ──
|
||||
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 {
|
||||
// URL parse failure — fail open, let other checks decide
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2.9: qtSd models require a quota-pool allocation ──
|
||||
//
|
||||
// quotaShared-* (qtSd/<group>/<provider>/<model>) virtual models are pool-gated:
|
||||
// a key that is NOT allocated to any quota pool (empty allowedQuotas) must not be
|
||||
// able to call them — otherwise an ordinary key could route through someone
|
||||
// else's shared quota. Only allocated keys (allowedQuotas non-empty, further
|
||||
// validated against their pool scope in Check 3 below) may use qtSd models.
|
||||
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" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 3: Quota-exclusive enforcement (Phase B4) ──
|
||||
//
|
||||
// When a key has allowedQuotas its access is governed exclusively by the
|
||||
// quotaShared-* virtual models of its pools — raw model names are rejected,
|
||||
// and quotaShared-* names belonging to OTHER pools are also rejected.
|
||||
// Normal allowedModels/allowedCombos checks are skipped for these keys.
|
||||
if (modelStr && apiKeyInfo.allowedQuotas && apiKeyInfo.allowedQuotas.length > 0) {
|
||||
try {
|
||||
const scope = await resolveQuotaKeyScope(apiKeyInfo.allowedQuotas);
|
||||
let quotaRejectionMsg: string | null = null;
|
||||
|
||||
if (isQuotaModelName(modelStr)) {
|
||||
// Virtual quota model — must belong to one of this key's pools AND its provider must be in scope.
|
||||
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 {
|
||||
// Raw (non-quotaShared) model name — always rejected for quota-exclusive keys.
|
||||
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" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Model is an in-scope quotaShared-* name — skip allowedModels/allowedCombos.
|
||||
// Continue to budget / rate-limit checks below.
|
||||
} 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"
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: Model restriction (skipped when allowedQuotas governs access) ──
|
||||
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) {
|
||||
// Short-circuit: auto/* and qtSd/* are combo-routed (not catalog models).
|
||||
// They must never be evaluated by the published-model gate.
|
||||
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
|
||||
requestedComboName = modelStr; // non-null sentinel — skips the published-model check
|
||||
} 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: policyErrorResponse(
|
||||
request,
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Model "${modelStr}" is not allowed for this API key`,
|
||||
`Model "${modelStr}" is not enabled or quota is insufficient. Choose another allowed model.`,
|
||||
"invalid_request_error",
|
||||
HTTP_STATUS.BAD_REQUEST
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: Budget limit ──
|
||||
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) {
|
||||
// Fail-closed: budget backend error should block request
|
||||
log.error("API_POLICY", "Budget check failed. Request blocked.", { error });
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Budget policy unavailable"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4.5: Per-model / per-provider token limits (Tier 1) ──
|
||||
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) {
|
||||
// Fail-closed: token-limit backend error should block the request,
|
||||
// consistent with the budget check above.
|
||||
log.error("API_POLICY", "Token limit check failed. Request blocked.", { error });
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Token limit policy unavailable"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: Generic Multi-Window Rate Limits ──
|
||||
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];
|
||||
|
||||
// Combine with legacy limits if they exist and custom rate limits aren't set
|
||||
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.`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 6: Soft throttle / slowdown ──
|
||||
if (apiKeyInfo.throttleDelayMs && apiKeyInfo.throttleDelayMs > 0) {
|
||||
await delay(Math.min(apiKeyInfo.throttleDelayMs, 300_000));
|
||||
}
|
||||
const budgetRejection = validateBudget(context);
|
||||
if (budgetRejection) return { apiKey, apiKeyInfo, rejection: budgetRejection };
|
||||
const tokenRejection = validateTokenLimit(context);
|
||||
if (tokenRejection) return { apiKey, apiKeyInfo, rejection: tokenRejection };
|
||||
const rateRejection = await validateRateLimitAndThrottle(context);
|
||||
if (rateRejection) return { apiKey, apiKeyInfo, rejection: rateRejection };
|
||||
|
||||
return { apiKey, apiKeyInfo, rejection: null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user