Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 31 additions & 2 deletions apps/web/src/lib/model-allow.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,41 @@ describe('model access predicates', () => {
await expect(isAllowed('openai/gpt-4o')).resolves.toBe(true);
});

test('provider allow list permits models without OpenRouter provider metadata', async () => {
test('provider allow list denies models missing from the current snapshot', async () => {
const isAllowed = createAllowPredicateFromProviderAllowList(undefined, ['openai'], lookup({}));

await expect(isAllowed('custom-llm-id')).resolves.toBe(true);
await expect(isAllowed('grok-4.5')).resolves.toBe(false);
});

test('enterprise deny lists require models to exist in the current snapshot', async () => {
const isAllowed = createAllowPredicateFromRestrictions(
{
requireModelInCurrentSnapshot: true,
modelDenyList: ['x-ai/grok-4.5'],
},
lookup({ 'x-ai/grok-4.6': ['x-ai'] })
);

await expect(isAllowed('grok-4.5')).resolves.toBe(false);
await expect(isAllowed('x-ai/grok-4.6')).resolves.toBe(true);
});

test.each(['kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])(
'keeps %s exempt from Enterprise model restrictions',
async modelId => {
const isAllowed = createAllowPredicateFromRestrictions(
{
requireModelInCurrentSnapshot: true,
providerAllowList: [],
modelDenyList: [modelId],
},
lookup({})
);

await expect(isAllowed(modelId)).resolves.toBe(true);
}
);

test('provider allow list still applies model deny list', async () => {
const isAllowed = createAllowPredicateFromProviderAllowList(
['openai/gpt-4o'],
Expand Down
23 changes: 18 additions & 5 deletions apps/web/src/lib/model-allow.server.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,49 @@
import 'server-only';
import { normalizeModelId } from '@/lib/ai-gateway/model-utils';
import { CUSTOM_LLM_PREFIX, normalizeModelId } from '@/lib/ai-gateway/model-utils';
import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok';
import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server';

export type ProviderAwareAllowPredicate = (modelId: string) => Promise<boolean>;

export type ModelRestrictions = {
requireModelInCurrentSnapshot?: boolean;
providerAllowList?: string[];
modelDenyList: string[];
};

export type ProviderLookup = (modelId: string) => Promise<ReadonlySet<string>>;

export async function isModelRestrictionExempt(modelId: string): Promise<boolean> {
const requestedModelId = modelId.trim().toLowerCase();
if (requestedModelId.startsWith(CUSTOM_LLM_PREFIX)) return true;
const directByokModel = await getDirectByokModel(requestedModelId);
return directByokModel.provider !== null && directByokModel.model !== null;
}

export function hasActiveModelRestrictions(restrictions: ModelRestrictions): boolean {
return restrictions.providerAllowList !== undefined || restrictions.modelDenyList.length > 0;
}

export function createAllowPredicateFromProviderAllowList(
modelDenyList: string[] | undefined,
providerAllowList: string[] | undefined,
providerLookup: ProviderLookup = getProviderSlugsForModel
providerLookup: ProviderLookup = getProviderSlugsForModel,
requireModelInCurrentSnapshot = providerAllowList !== undefined
): ProviderAwareAllowPredicate {
const modelDenySet = new Set(modelDenyList?.map(normalizeModelId));
const providerAllowSet = providerAllowList ? new Set(providerAllowList) : undefined;
return async (modelId: string): Promise<boolean> => {
const normalizedModelId = normalizeModelId(modelId);
if (await isModelRestrictionExempt(modelId)) return true;
if (modelDenySet.has(normalizedModelId)) {
return false;
}
if (!providerAllowSet) {
if (!providerAllowSet && !requireModelInCurrentSnapshot) {
return true;
}
const providerSlugs = await providerLookup(normalizedModelId);
if (providerSlugs.size === 0) return true;
if (providerSlugs.size === 0) return false;
if (!providerAllowSet) return true;
return [...providerSlugs].some(slug => providerAllowSet.has(slug));
};
}
Expand All @@ -43,6 +55,7 @@ export function createAllowPredicateFromRestrictions(
return createAllowPredicateFromProviderAllowList(
restrictions.modelDenyList,
restrictions.providerAllowList,
providerLookup
providerLookup,
restrictions.requireModelInCurrentSnapshot
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ function context(
};
}

const currentSnapshotLookup = async (modelId: string) =>
new Set(
{
'anthropic/claude': ['anthropic'],
'openai/gpt-4o': ['openai'],
'openai/o3': ['openai'],
}[modelId] ?? []
);

describe('effective organization model access', () => {
it('preserves current organization access outside Enterprise', async () => {
const policy = evaluateEffectiveModelAccessPolicy(
Expand All @@ -46,14 +55,45 @@ describe('effective organization model access', () => {
defaultPolicies: [],
})
);
expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true);
expect(
(await getEffectiveModelDecision(policy, 'anthropic/claude', async () => new Set())).allowed
).toBe(true);
});

it('denies Enterprise model aliases missing from the current snapshot', async () => {
const policy = evaluateEffectiveModelAccessPolicy(
context({
organization: {
...context().organization,
settings: { model_deny_list: ['x-ai/grok-4.5'] },
},
defaultPolicies: [{ type: 'model_access', data: { mode: 'all' } }],
})
);

const decision = await getEffectiveModelDecision(policy, 'grok-4.5', async () => new Set());

expect(decision).toEqual({ allowed: false, denialSource: 'organization_model' });
});

it.each(['kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])(
'keeps %s exempt from effective Enterprise restrictions',
async modelId => {
const policy = evaluateEffectiveModelAccessPolicy(context());

await expect(
getEffectiveModelDecision(policy, modelId, async () => new Set())
).resolves.toEqual({ allowed: true });
}
);

it('preserves organization access when no model access policy is configured', async () => {
const policy = evaluateEffectiveModelAccessPolicy(
context({ defaultPolicies: [], groupPolicies: [] })
);
expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true);
expect(
(await getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup)).allowed
).toBe(true);
expect((await getEffectiveModelDecision(policy, 'openai/o3')).allowed).toBe(false);
});

Expand Down Expand Up @@ -87,15 +127,21 @@ describe('effective organization model access', () => {
],
})
);
expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true);
expect((await getEffectiveModelDecision(policy, 'openai/gpt-4o')).allowed).toBe(false);
expect(
(await getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup)).allowed
).toBe(true);
expect(
(await getEffectiveModelDecision(policy, 'openai/gpt-4o', currentSnapshotLookup)).allowed
).toBe(false);
});

it('lets all dominate selected and none within the organization ceiling', async () => {
const policy = evaluateEffectiveModelAccessPolicy(
context({ groupPolicies: [[{ type: 'model_access', data: { mode: 'all' } }]] })
);
expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true);
expect(
(await getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup)).allowed
).toBe(true);
expect((await getEffectiveModelDecision(policy, 'openai/o3')).allowed).toBe(false);
});

Expand All @@ -117,7 +163,7 @@ describe('effective organization model access', () => {
'unknown/model',
async () => new Set()
);
expect(decision).toMatchObject({ allowed: false, denialSource: 'group_provider' });
expect(decision).toMatchObject({ allowed: false, denialSource: 'organization_model' });
});

it('intersects provider-derived grants with the organization ceiling', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { desc, eq } from 'drizzle-orm';
import { normalizeModelId } from '@/lib/ai-gateway/model-utils';
import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server';
import { isModelRestrictionExempt } from '@/lib/model-allow.server';
import { db } from '@/lib/drizzle';
import {
getOrganizationGroupPolicyContext,
type OrganizationGroupPolicyContext,
} from '@/lib/organizations/organization-group-policy-context.server';

export type EffectiveOrganizationModelPolicy = {
requireModelInCurrentSnapshot: boolean;
organizationModelDenyList: string[];
organizationProviderCeiling?: string[];
memberGrant:
Expand Down Expand Up @@ -47,9 +49,12 @@ export function evaluateEffectiveModelAccessPolicy(
const organizationProviderCeiling = organizationRestrictionsEnabled
? context.organization.settings.provider_allow_list
: undefined;
const requireModelInCurrentSnapshot =
organizationModelDenyList.length > 0 || organizationProviderCeiling !== undefined;

if (!organizationRestrictionsEnabled) {
return {
requireModelInCurrentSnapshot: false,
organizationModelDenyList,
organizationProviderCeiling,
memberGrant: { mode: 'unrestricted' },
Expand All @@ -63,6 +68,7 @@ export function evaluateEffectiveModelAccessPolicy(
.filter(policy => policy.type === 'model_access');
if (policies.length === 0 || policies.some(policy => policy.data.mode === 'all')) {
return {
requireModelInCurrentSnapshot,
organizationModelDenyList,
organizationProviderCeiling,
memberGrant: { mode: 'unrestricted' },
Expand All @@ -73,6 +79,7 @@ export function evaluateEffectiveModelAccessPolicy(

const selectedPolicies = policies.filter(policy => policy.data.mode === 'selected');
return {
requireModelInCurrentSnapshot,
organizationModelDenyList,
organizationProviderCeiling,
memberGrant: {
Expand Down Expand Up @@ -105,18 +112,27 @@ export async function getEffectiveModelDecision(
providerLookup: ProviderLookup = getProviderSlugsForModel
): Promise<EffectiveModelDecision> {
const normalizedModelId = normalizeModelId(modelId);
if (await isModelRestrictionExempt(modelId)) {
return { allowed: true };
}
if (policy.organizationModelDenyList.includes(normalizedModelId)) {
return { allowed: false, denialSource: 'organization_model' };
}
const currentModelProviders = policy.requireModelInCurrentSnapshot
? await providerLookup(normalizedModelId)
: undefined;
if (currentModelProviders?.size === 0) {
return { allowed: false, denialSource: 'organization_model' };
Comment thread
chrarnoldus marked this conversation as resolved.
}
const organizationRoutes = policy.organizationProviderCeiling
? new Set(policy.organizationProviderCeiling)
: undefined;

async function decisionWithinOrganizationCeiling(): Promise<EffectiveModelDecision> {
if (!organizationRoutes) return { allowed: true };
const modelProviders = await providerLookup(normalizedModelId);
const modelProviders = currentModelProviders ?? (await providerLookup(normalizedModelId));
if (modelProviders.size === 0) {
return { allowed: true, eligibleProviderRoutes: organizationRoutes };
return { allowed: false, denialSource: 'organization_model' };
}
const eligibleProviderRoutes = new Set(
[...modelProviders].filter(provider => organizationRoutes.has(provider))
Expand All @@ -135,7 +151,7 @@ export async function getEffectiveModelDecision(
if (policy.memberGrant.providerAllowList.length === 0) {
return { allowed: false, denialSource: 'no_grant' };
}
const modelProviders = await providerLookup(normalizedModelId);
const modelProviders = currentModelProviders ?? (await providerLookup(normalizedModelId));
if (modelProviders.size === 0) {
return { allowed: false, denialSource: 'group_provider' };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('legacy organization restrictions with no groups or policies', () => {
'denied/model': false,
'allowed/model': true,
'blocked-provider/model': false,
'unknown-routes/model': true,
'unknown-routes/model': false,
});

// Reading policy must not materialize a settings row or invent a revision.
Expand All @@ -141,6 +141,7 @@ describe('legacy organization restrictions with no groups or policies', () => {
const { legacy } = await expectParityWithLegacyPredicate(organization, owner.id);
expect(legacy['denied/model']).toBe(false);
expect(legacy['blocked-provider/model']).toBe(true);
expect(legacy['unknown-routes/model']).toBe(false);
});

it('matches the pre-groups predicate for a provider allow list only', async () => {
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/lib/organizations/model-restrictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ export function getEffectiveModelRestrictions(organization: Organization): Model
if (organization.plan !== 'enterprise') {
return { modelDenyList: [] };
}
const providerAllowList = organization.settings?.provider_allow_list;
const modelDenyList = organization.settings?.model_deny_list ?? [];
return {
providerAllowList: organization.settings?.provider_allow_list,
modelDenyList: organization.settings?.model_deny_list ?? [],
requireModelInCurrentSnapshot: providerAllowList !== undefined || modelDenyList.length > 0,
providerAllowList,
modelDenyList,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ const mockedIsPublicIdExperimented = isPublicIdExperimented as unknown as jest.M
describe('organizations settings trpc router', () => {
beforeEach(() => {
mockedGetProviderSlugsForModel.mockReset();
mockedGetProviderSlugsForModel.mockImplementation(async modelId => {
const provider = {
'anthropic/claude-3-opus': 'anthropic',
'gpt-3.5-turbo': 'openai',
'gpt-4': 'openai',
'openai/gpt-4o': 'openai',
}[modelId];
return provider ? new Set([provider]) : new Set();
});
mockedGetEnhancedOpenRouterModels.mockReset();
mockedIsPublicIdExperimented.mockReset();
mockedIsPublicIdExperimented.mockResolvedValue(false);
Expand Down Expand Up @@ -582,6 +591,17 @@ describe('organizations settings trpc router', () => {
expect(result.settings.default_model).toBe('any-model');
});

it('keeps custom LLM defaults exempt from Enterprise model restrictions', async () => {
const caller = await createCallerForUser(owner.id);

const result = await caller.organizations.settings.updateDefaultModel({
organizationId: orgWithModelDenyList.id,
default_model: 'kilo-internal/private-model',
});

expect(result.settings.default_model).toBe('kilo-internal/private-model');
});

it('should throw UNAUTHORIZED error for non-owner users', async () => {
const caller = await createCallerForUser(member.id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,9 @@ export const organizationsSettingsRouter = createTRPCRouter({
currentSettings.default_model !== ORG_AUTO_MODEL.id
) {
const isAllowed = createAllowPredicateFromRestrictions({
requireModelInCurrentSnapshot:
settingsUpdate.provider_allow_list !== undefined ||
(settingsUpdate.model_deny_list?.length ?? 0) > 0,
providerAllowList: settingsUpdate.provider_allow_list,
modelDenyList: settingsUpdate.model_deny_list ?? [],
});
Expand Down