Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 11 additions & 302 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts

Large diffs are not rendered by default.

162 changes: 7 additions & 155 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,6 @@ import {
checkPromotionLimit,
} from '@/lib/free-model-rate-limiter';
import { PROMOTION_MAX_REQUESTS, PROMOTION_WINDOW_HOURS } from '@/lib/constants';
import {
classifyAbuse,
awaitClassifyAbuse,
cacheRulesEngineAction,
getCachedRulesEngineAction,
getQuarantineFreeModel,
getRulesEngineActionDecision,
isRulesEngineBlockingAction,
resolveAbuseClassificationCacheIdentityKey,
sleepForRulesEngineAction,
} from '@/lib/ai-gateway/abuse-service';
import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server';
import { normalizeModelId } from '@/lib/ai-gateway/model-utils';
import { isUnavailableModel } from '@/lib/ai-gateway/unavailable-models';
Expand Down Expand Up @@ -403,7 +392,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
routingTarget = autoResult.routingTarget ?? null;
}

let effectiveModelIdLowerCased = requestBodyParsed.body.model.toLowerCase();
const effectiveModelIdLowerCased = requestBodyParsed.body.model.toLowerCase();

if (!ipAddress) {
return NextResponse.json(
Expand Down Expand Up @@ -513,7 +502,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

// Bill the classifier overhead as soon as the cost is known and we have an
// authenticated user — via after(), so the row is persisted even when the
// request is rejected downstream (abuse block, provider/api-kind rejection,
// request is rejected downstream (provider/api-kind rejection,
// balance/org checks, upstream 4xx, …). The classifier already ran on Kilo's
// OpenRouter credential during model resolution, so the cost is owed
// regardless of how this request ends. Anonymous requests never reach a
Expand Down Expand Up @@ -661,11 +650,9 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
};
}

let accessCheckResolver = createAccessCheckResolver(effectiveModelIdLowerCased);
const accessCheckResolver = createAccessCheckResolver(effectiveModelIdLowerCased);

// Resolve the initial provider before abuse enforcement because abuse needs
// provider/BYOK context, and quarantine-3 may later rewrite these values.
const initialProviderResultForAbuseService = await getProvider({
const providerResult = await getProvider({
requestedModel: effectiveModelIdLowerCased,
request: requestBodyParsed,
user,
Expand All @@ -675,15 +662,15 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
machineId: machineIdHeader,
getRoutingProviderConfig: accessCheckResolver.getRoutingProviderConfig,
});
if (initialProviderResultForAbuseService.kind === 'not-found') {
if (providerResult.kind === 'not-found') {
// Paused experiment for this public id — return a local model-unavailable
// response instead of silently falling through to default routing.
return modelDoesNotExistResponse();
}
if (initialProviderResultForAbuseService.kind === 'unavailable') {
if (providerResult.kind === 'unavailable') {
return temporarilyUnavailableResponse();
}
let effectiveProviderContext = initialProviderResultForAbuseService;
let effectiveProviderContext = providerResult;

if (autoModel === ORG_AUTO_MODEL.id && routingTarget) {
try {
Expand All @@ -707,26 +694,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
);
}

// Start classification early, but do not await it unless the last cached
// rules-engine result says this identity is already under enforcement.
const classifyPromise = classifyAbuse(request, requestBodyParsed, {
kiloUserId: user.id,
organizationId,
projectId,
provider: effectiveProviderContext.provider.id,
isByok: !!effectiveProviderContext.userByok,
feature,
});
const abuseCacheIdentityKey = await resolveAbuseClassificationCacheIdentityKey({
kiloUserId: user.id,
fraudHeaders,
});
const cachedAction = await getCachedRulesEngineAction(abuseCacheIdentityKey);
const cachedRulesEngineAction = cachedAction?.action ?? null;
// Cache-gating keeps normal traffic on the fast path: only identities with a
// previously blocking/quarantine decision wait for a fresh abuse-service result.
const shouldBlockOnClassify = isRulesEngineBlockingAction(cachedRulesEngineAction);

// Large responses may run longer than the 800s serverless function timeout.
const requestMaxTokens = getMaxTokens(requestBodyParsed);
if (requestMaxTokens && requestMaxTokens > MAX_TOKENS_LIMIT) {
Expand All @@ -745,103 +712,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
return unavailableModelResponse();
}

let classifyResult = shouldBlockOnClassify ? await awaitClassifyAbuse(classifyPromise) : null;
if (classifyResult?.rules_engine) {
await cacheRulesEngineAction({
identityKey: classifyResult.context?.identity_key ?? abuseCacheIdentityKey,
rulesEngine: classifyResult.rules_engine,
});
}
// When a blocking refresh fails or times out, fall back to the cached
// enforcement decision. Missing/nonblocking cache entries never enforce the
// fresh result on this request; they only update Redis for the next request.
const rulesEngineActionForDecision =
(shouldBlockOnClassify ? classifyResult?.rules_engine?.resolved_action : null) ??
(shouldBlockOnClassify ? cachedAction?.action : null);
const rulesEngineDecision = getRulesEngineActionDecision({
action: rulesEngineActionForDecision,
userByok: !!effectiveProviderContext.userByok,
quarantineFreeModel:
rulesEngineActionForDecision === 'quarantine-3' && !effectiveProviderContext.userByok
? await getQuarantineFreeModel(requestBodyParsed.kind)
: null,
});
if (classifyResult) {
console.log('Abuse classification result:', {
rules_engine_resolved_action: classifyResult.rules_engine?.resolved_action ?? null,
rules_engine_sus_score: classifyResult.rules_engine?.sus_score ?? null,
rules_engine_matched_abuse_rule_ids:
classifyResult.rules_engine?.matched_abuse_rule_ids ?? [],
identity_key: classifyResult.context?.identity_key,
kilo_user_id: user.id,
requested_model: effectiveModelIdLowerCased,
rps: classifyResult.context?.requests_per_second,
request_id: classifyResult.request_id,
});
}
if (rulesEngineDecision.response) {
return rulesEngineDecision.response;
}
let abuseDowngradedFrom: string | null = null;
if (rulesEngineDecision.modelOverride) {
// Quarantine-3 rewrites non-BYOK requests to an auto-free candidate, so the
// provider and derived policy flags must be resolved again for that model.
abuseDowngradedFrom = effectiveModelIdLowerCased;
requestBodyParsed.body.model = rulesEngineDecision.modelOverride;
effectiveModelIdLowerCased = rulesEngineDecision.modelOverride;
accessCheckResolver = createAccessCheckResolver(effectiveModelIdLowerCased);
const quarantineProviderResult = await getProvider({
requestedModel: effectiveModelIdLowerCased,
request: requestBodyParsed,
user,
organizationId,
taskId,
clientIp: ipAddress ?? null,
machineId: machineIdHeader,
getRoutingProviderConfig: accessCheckResolver.getRoutingProviderConfig,
});
if (quarantineProviderResult.kind === 'not-found') {
if (rulesEngineDecision.delayMs > 0) {
await sleepForRulesEngineAction(rulesEngineDecision.delayMs);
}
return modelDoesNotExistResponse();
}
if (quarantineProviderResult.kind === 'unavailable') {
if (rulesEngineDecision.delayMs > 0) {
await sleepForRulesEngineAction(rulesEngineDecision.delayMs);
}
return temporarilyUnavailableResponse();
}

effectiveProviderContext = quarantineProviderResult;

console.warn('SECURITY: Abuse quarantine-3 model override applied', {
kilo_user_id: user.id,
identity_key: classifyResult?.context?.identity_key ?? abuseCacheIdentityKey,
abuse_request_id: classifyResult?.request_id ?? null,
rules_engine_action: rulesEngineDecision.action,
rules_engine_matched_abuse_rule_ids:
classifyResult?.rules_engine?.matched_abuse_rule_ids ?? [],
original_model: abuseDowngradedFrom,
overridden_model: effectiveModelIdLowerCased,
original_provider: initialProviderResultForAbuseService.provider.id,
overridden_provider: effectiveProviderContext.provider.id,
user_byok: !!effectiveProviderContext.userByok,
feature,
project_id: projectId,
});

if (!effectiveProviderContext.provider.supportedChatApis.includes(requestBodyParsed.kind)) {
if (rulesEngineDecision.delayMs > 0) {
await sleepForRulesEngineAction(rulesEngineDecision.delayMs);
}
return apiKindNotSupportedResponse(
requestBodyParsed.kind,
effectiveProviderContext.provider.supportedChatApis
);
}
}

// Skip balance/org checks for anonymous users - they can only use free models
if (!isAnonymousContext(user) && !effectiveProviderContext.bypassAccessCheck) {
const {
Expand Down Expand Up @@ -952,8 +822,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
mode: modeHeader,
auto_model: autoModel,
ttfb_ms: null,
abuse_delay: rulesEngineDecision.delayMs > 0 ? rulesEngineDecision.delayMs : null,
abuse_downgraded_from: abuseDowngradedFrom,
clientRequestId,
};

Expand Down Expand Up @@ -1007,7 +875,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
...upstreamAttemptOptions,
providerContext: effectiveProviderContext,
request: requestBodyParsed,
delayMs: rulesEngineDecision.delayMs,
});
if (attempt.type === 'invalid-openrouter-model') {
return modelDoesNotExistOnOpenRouterResponse(effectiveModelIdLowerCased);
Expand Down Expand Up @@ -1051,7 +918,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
...upstreamAttemptOptions,
providerContext: effectiveProviderContext,
request: requestBodyParsed,
delayMs: 0,
});
if (attempt.type === 'invalid-openrouter-model') {
return modelDoesNotExistOnOpenRouterResponse(effectiveModelIdLowerCased);
Expand Down Expand Up @@ -1124,20 +990,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

const clonedReponse = response.clone(); // reading from body is side-effectful

if (!shouldBlockOnClassify) {
classifyResult = await awaitClassifyAbuse(classifyPromise);
if (classifyResult?.rules_engine) {
await cacheRulesEngineAction({
identityKey: classifyResult.context?.identity_key ?? abuseCacheIdentityKey,
rulesEngine: classifyResult.rules_engine,
});
}
}

if (classifyResult) {
usageContext.abuse_request_id = classifyResult.request_id;
}

accountForMicrodollarUsage(clonedReponse, usageContext, openrouterRequestSpan);

const requestLogging = {
Expand Down
Loading