diff --git a/apps/web/src/app/api/openrouter/[...path]/route.test.ts b/apps/web/src/app/api/openrouter/[...path]/route.test.ts index 4535305ddf..a7b2cfb8b9 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.test.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.test.ts @@ -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); diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index 200cd4c822..611de0af8b 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -602,6 +602,64 @@ export async function POST(request: NextRequest): Promise 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 | 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({ @@ -612,6 +670,7 @@ export async function POST(request: NextRequest): Promise 0) { @@ -780,7 +841,15 @@ export async function POST(request: NextRequest): Promise 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` diff --git a/apps/web/src/lib/ai-gateway/providers/get-provider.ts b/apps/web/src/lib/ai-gateway/providers/get-provider.ts index aeb9184d81..579b24107d 100644 --- a/apps/web/src/lib/ai-gateway/providers/get-provider.ts +++ b/apps/web/src/lib/ai-gateway/providers/get-provider.ts @@ -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'; @@ -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; }; export async function getProvider(input: GetProviderInput): Promise { - 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) { @@ -267,10 +282,17 @@ export async function getProvider(input: GetProviderInput): Promise + (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', diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/index.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/index.test.ts index e9f98aef10..3d3fe50c1f 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/index.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/index.test.ts @@ -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 { diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/index.ts b/apps/web/src/lib/ai-gateway/providers/vercel/index.ts index 245968c286..cb9983d746 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/index.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/index.ts @@ -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'; @@ -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 ) { const routingConfig = await getRuntimeGatewayRoutingConfig(); if (isVercelRoutingOptOut(requestedModel, routingConfig.vercelOptOutModels)) { @@ -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 =