Skip to content
48 changes: 48 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,54 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () =>
expect(mockedGetBalanceAndOrgSettings.mock.calls[0]?.[2]).toBe(readDb);
});

it('selects the provider after applying the organization provider allow-list', async () => {
mockedGetUserFromAuth.mockResolvedValue({
user: {
id: 'user-123',
google_user_email: 'test@example.com',
microdollars_used: 0,
} as User,
authFailedResponse: null,
organizationId: 'org-1',
});
mockedGetBalanceAndOrgSettings.mockResolvedValue({
balance: 1000,
settings: { provider_allow_list: ['amazon-bedrock'] },
plan: 'enterprise',
});

const { POST } = await import('./route');
const response = await POST(makeRequest(makeBody('anthropic/claude-sonnet-4.5')) as never);

expect(response.status).toBe(200);
const getRoutingProviderConfig = mockedGetProvider.mock.calls[0]?.[0].getRoutingProviderConfig;
expect(getRoutingProviderConfig).toBeDefined();
expect((await getRoutingProviderConfig?.())?.only).toEqual(['amazon-bedrock']);
});

it('skips group policy evaluation when organization policy denies the model', async () => {
mockedGetUserFromAuth.mockResolvedValue({
user: {
id: 'user-123',
google_user_email: 'test@example.com',
microdollars_used: 0,
} as User,
authFailedResponse: null,
organizationId: 'org-1',
});
mockedGetBalanceAndOrgSettings.mockResolvedValue({
balance: 1000,
settings: { model_deny_list: ['openai/gpt-4o'] },
plan: 'enterprise',
});

const { POST } = await import('./route');
const response = await POST(makeRequest(makeBody()) as never);

expect(response.status).toBe(404);
expect(mockedGetEffectiveModelDecision).not.toHaveBeenCalled();
});

it('returns 404 when the OpenRouter model id is unknown', async () => {
mockedIsValidOpenRouterModelId.mockResolvedValue(false);

Expand Down
99 changes: 73 additions & 26 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,64 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
);
}

async function resolveAccessCheck(modelId: string) {
const { balance, settings, plan } = await balanceAndSettingsPromise;
const { error: modelRestrictionError, providerConfig } = checkOrganizationModelRestrictions({
Comment thread
chrarnoldus marked this conversation as resolved.
modelId,
settings,
organizationPlan: plan,
});
if (modelRestrictionError) {
return {
balance,
effectiveProviderConfig: providerConfig,
groupModelAllowed: true,
groupProvidersAllowed: true,
modelRestrictionError,
plan,
settings,
};
}
let effectiveProviderConfig = providerConfig;
let groupModelAllowed = true;
let groupProvidersAllowed = true;
const groupPolicy = await organizationGroupPolicyPromise;
if (groupPolicy) {
const groupDecision = await getEffectiveModelDecision(groupPolicy, modelId);
groupModelAllowed = groupDecision.allowed;
if (groupDecision.eligibleProviderRoutes) {
const currentOnly = providerConfig?.only;
const only = currentOnly
? currentOnly.filter(provider => groupDecision.eligibleProviderRoutes?.has(provider))
: [...groupDecision.eligibleProviderRoutes];
groupProvidersAllowed = only.length > 0;
effectiveProviderConfig = { ...providerConfig, only };
}
}
return {
balance,
effectiveProviderConfig,
groupModelAllowed,
groupProvidersAllowed,
modelRestrictionError,
plan,
settings,
};
}

function createAccessCheckResolver(modelId: string) {
let accessCheck: ReturnType<typeof resolveAccessCheck> | undefined;
const get = () => (accessCheck ??= resolveAccessCheck(modelId));
return {
get,
getRoutingProviderConfig: isAnonymousContext(user)
? undefined
: async () => (await get()).effectiveProviderConfig,
};
}

let 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({
Expand All @@ -612,6 +670,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
taskId,
clientIp: ipAddress ?? null,
machineId: machineIdHeader,
getRoutingProviderConfig: accessCheckResolver.getRoutingProviderConfig,
});
if (initialProviderResultForAbuseService.kind === 'not-found') {
// Paused experiment for this public id — return a local model-unavailable
Expand Down Expand Up @@ -727,6 +786,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
abuseDowngradedFrom = effectiveModelIdLowerCased;
requestBodyParsed.body.model = rulesEngineDecision.modelOverride;
effectiveModelIdLowerCased = rulesEngineDecision.modelOverride;
accessCheckResolver = createAccessCheckResolver(effectiveModelIdLowerCased);
const quarantineProviderResult = await getProvider({
requestedModel: effectiveModelIdLowerCased,
request: requestBodyParsed,
Expand All @@ -735,6 +795,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
taskId,
clientIp: ipAddress ?? null,
machineId: machineIdHeader,
getRoutingProviderConfig: accessCheckResolver.getRoutingProviderConfig,
});
if (quarantineProviderResult.kind === 'not-found') {
if (rulesEngineDecision.delayMs > 0) {
Expand Down Expand Up @@ -780,7 +841,15 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

// Skip balance/org checks for anonymous users - they can only use free models
if (!isAnonymousContext(user) && !effectiveProviderContext.bypassAccessCheck) {
const { balance, settings, plan } = await balanceAndSettingsPromise;
const {
balance,
effectiveProviderConfig,
groupModelAllowed,
groupProvidersAllowed,
modelRestrictionError,
plan,
settings,
} = await accessCheckResolver.get();

if (
balance <= 0 &&
Expand All @@ -792,36 +861,14 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

// Organization model/provider restrictions check
// Provider/model access policy applies to Enterprise plans; data collection applies to all plans.
const { error: modelRestrictionError, providerConfig } = checkOrganizationModelRestrictions({
modelId: effectiveModelIdLowerCased,
settings,
organizationPlan: plan,
});
if (modelRestrictionError) {
return isAutoEfficientRequest ? efficientPoolBlockedResponse() : modelRestrictionError;
}

let effectiveProviderConfig = providerConfig;
const groupPolicy = await organizationGroupPolicyPromise;
if (groupPolicy) {
// Started right after auth so the DB read overlapped the work above; the
// decision itself is in-memory against the cached provider index.
const groupDecision = await getEffectiveModelDecision(
groupPolicy,
effectiveModelIdLowerCased
);
if (!groupDecision.allowed) {
return isAutoEfficientRequest ? efficientPoolBlockedResponse() : modelNotAllowedResponse();
}
if (groupDecision.eligibleProviderRoutes) {
const currentOnly = providerConfig?.only;
const only = currentOnly
? currentOnly.filter(provider => groupDecision.eligibleProviderRoutes?.has(provider))
: [...groupDecision.eligibleProviderRoutes];
if (only.length === 0) return modelNotAllowedResponse();
effectiveProviderConfig = { ...providerConfig, only };
}
if (!groupModelAllowed) {
return isAutoEfficientRequest ? efficientPoolBlockedResponse() : modelNotAllowedResponse();
}
if (!groupProvidersAllowed) return modelNotAllowedResponse();

// Experiment traffic captures prompts to R2 for partner evaluation, which
// is a form of data collection that the gateway-pinned `data_collection`
Expand Down
28 changes: 25 additions & 3 deletions apps/web/src/lib/ai-gateway/providers/get-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { GatewayRequest } from '@/lib/ai-gateway/providers/openrouter/types';
import type {
GatewayRequest,
OpenRouterProviderConfig,
} from '@/lib/ai-gateway/providers/openrouter/types';
import { shouldRouteToVercel } from '@/lib/ai-gateway/providers/vercel';
import { findKiloExclusiveModel, isKiloExclusiveModel } from '@/lib/ai-gateway/models';
import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils';
Expand Down Expand Up @@ -195,10 +198,22 @@ export type GetProviderInput = {
/** Machine identifier from `x-kilocode-machineid`. Used as the machine-
* cohort allocation subject for experiment routing. */
machineId: string | null;
/** Resolves organization/group provider policy only when selecting a managed
* gateway. Direct BYOK and custom LLM routes remain exempt. */
getRoutingProviderConfig?: () => Promise<OpenRouterProviderConfig | undefined>;
};

export async function getProvider(input: GetProviderInput): Promise<GetProviderResult> {
const { requestedModel, request, user, organizationId, taskId, clientIp, machineId } = input;
const {
requestedModel,
request,
user,
organizationId,
taskId,
clientIp,
machineId,
getRoutingProviderConfig,
} = input;

const directByokByok = await checkDirectBYOK(user, requestedModel, organizationId);
if (directByokByok) {
Expand Down Expand Up @@ -267,10 +282,17 @@ export async function getProvider(input: GetProviderInput): Promise<GetProviderR

const eligibleForVercelRouting =
!kiloExclusiveModel || kiloExclusiveModel.flags.includes('vercel-routing');
const resolveRoutingProviderConfig = async () =>
(await getRoutingProviderConfig?.()) ?? request.body.provider;

if (
eligibleForVercelRouting &&
(await shouldRouteToVercel(requestedModel, request, taskId || user.id))
(await shouldRouteToVercel(
requestedModel,
request,
taskId || user.id,
resolveRoutingProviderConfig
))
) {
return {
kind: 'provider',
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/vercel/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,78 @@ describe('convertProviderOptions', () => {
});
});

describe('shouldRouteToVercel', () => {
function request(provider?: GatewayRequest['body']['provider']): GatewayRequest {
return {
kind: 'chat_completions',
body: {
model: 'anthropic/claude-sonnet-4.5',
messages: [{ role: 'user', content: 'hello' }],
provider,
},
};
}

async function loadShouldRouteToVercel(options?: { optOut?: boolean }) {
jest.resetModules();
jest.doMock('@/lib/ai-gateway/providers/routing-config', () => ({
getRuntimeGatewayRoutingConfig: jest.fn(async () => ({
vercelPaid: 100,
vercelFree: 100,
vercelOptOutModels: new Set(options?.optOut ? ['anthropic/claude-sonnet-4.5'] : []),
friendli: 0,
perplexity: 0,
})),
}));
jest.doMock('@/lib/ai-gateway/is-free-model', () => ({
isFreeModel: jest.fn(async () => false),
}));
jest.doMock('@/lib/ai-gateway/providers/gateway-models-cache', () => ({
getVercelModelsFromRedis: jest.fn(async () => new Set(['anthropic/claude-sonnet-4.5'])),
getCachedVercelInferenceProviderIdsForModel: jest.fn(async () => ['anthropic']),
}));
return (await import('@/lib/ai-gateway/providers/vercel')).shouldRouteToVercel;
}

it('uses resolved provider policy instead of unrestricted request preferences', async () => {
const shouldRouteToVercel = await loadShouldRouteToVercel();

await expect(
shouldRouteToVercel('anthropic/claude-sonnet-4.5', request(), 'seed', async () => ({
only: ['google-vertex'],
}))
).resolves.toBe(false);
});

it('falls back to request preferences when no policy config is resolved', async () => {
const shouldRouteToVercel = await loadShouldRouteToVercel();

await expect(
shouldRouteToVercel(
'anthropic/claude-sonnet-4.5',
request({ only: ['anthropic'] }),
'seed',
async () => undefined
)
).resolves.toBe(true);
});

it('does not resolve provider policy for models opted out of Vercel routing', async () => {
const shouldRouteToVercel = await loadShouldRouteToVercel({ optOut: true });
const getRoutingProviderConfig = jest.fn(async () => ({ only: ['anthropic'] }));

await expect(
shouldRouteToVercel(
'anthropic/claude-sonnet-4.5',
request(),
'seed',
getRoutingProviderConfig
)
).resolves.toBe(false);
expect(getRoutingProviderConfig).not.toHaveBeenCalled();
});
});

describe('applyVercelSettings BYOK pinning', () => {
function byokRequest(ignore: string[]): GatewayRequest {
return {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/ai-gateway/providers/vercel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import type {
GatewayRequest,
OpenRouterProviderConfig,
VercelInferenceProviderConfig,
VercelProviderConfig,
} from '@/lib/ai-gateway/providers/openrouter/types';
Expand Down Expand Up @@ -62,7 +63,8 @@ export function isVercelRoutingOptOut(requestedModel: string, optOutModels: Read
export async function shouldRouteToVercel(
requestedModel: string,
request: GatewayRequest,
randomSeed: string
randomSeed: string,
getRoutingProviderConfig: () => Promise<OpenRouterProviderConfig | undefined>
) {
const routingConfig = await getRuntimeGatewayRoutingConfig();
if (isVercelRoutingOptOut(requestedModel, routingConfig.vercelOptOutModels)) {
Expand All @@ -88,7 +90,7 @@ export async function shouldRouteToVercel(
return false;
}

const provider = request.body.provider;
const provider = await getRoutingProviderConfig();
if (provider && (provider.only || provider.ignore?.length)) {
const { only, ignore } = provider;
const vercelInferenceProviders =
Expand Down