From 45f2acec6b04ea98f92ece5e7d85c07e2c74ca44 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Tue, 3 Mar 2026 16:15:28 +0100 Subject: [PATCH 01/14] Eliminate most uses of createProviderAwareModelAllowPredicate --- packages/db/src/schema-types.ts | 2 ++ .../api/organizations/[id]/defaults/route.ts | 16 ++++++------- src/lib/integrations/discord-service.ts | 8 +++---- src/lib/integrations/slack-service.ts | 8 +++---- src/lib/model-allow.client.ts | 1 + src/lib/model-allow.server.ts | 11 +++++++++ src/lib/slack-bot/model-allow-list.ts | 12 +++++----- .../organization-settings-router.ts | 24 +++++++++---------- 8 files changed, 48 insertions(+), 34 deletions(-) diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 82a983fe4f..8e14fc1a24 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -125,7 +125,9 @@ export const OrganizationPlanSchema = z.enum(['teams', 'enterprise']); export type OrganizationPlan = z.infer; const OrganizationSettingsSchema = z.object({ + /** @deprecated */ model_allow_list: z.array(z.string()).optional(), + /** @deprecated */ provider_allow_list: z.array(z.string()).optional(), // under development, not yet enforced, will replace model_allow_list and provider_allow_list: diff --git a/src/app/api/organizations/[id]/defaults/route.ts b/src/app/api/organizations/[id]/defaults/route.ts index bf5bd84449..a5d971657b 100644 --- a/src/app/api/organizations/[id]/defaults/route.ts +++ b/src/app/api/organizations/[id]/defaults/route.ts @@ -3,7 +3,7 @@ import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; import type { NextRequest } from 'next/server'; import { PRIMARY_DEFAULT_MODEL, getFirstFreeModel, preferredModels } from '@/lib/models'; import { getEnhancedOpenRouterModels } from '@/lib/providers/openrouter'; -import { createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server'; +import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; import { getModelIdToProviderSlugsIndex } from '@/lib/providers/openrouter/models-by-provider-index.server'; type DefaultsResponse = { @@ -26,9 +26,9 @@ export async function GET( // Get organization's default model setting let defaultModel = organization.settings?.default_model; - const allowList = organization.settings?.model_allow_list; + const denyList = organization.settings?.model_deny_list; - const isAllowed = createProviderAwareModelAllowPredicate(allowList ?? []); + const isAllowed = createAllowPredicateFromDenyList(denyList ?? []); const findFirstAllowedModel = async (modelIds: readonly string[]) => { for (const modelId of modelIds) { @@ -73,23 +73,23 @@ export async function GET( defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL]); if (!defaultModel) { - if (!allowList?.length) { + if (!denyList?.length) { defaultModel = PRIMARY_DEFAULT_MODEL; } else { - const firstConcreteAllowedModel = allowList.find(modelId => !modelId.endsWith('/*')); + const firstConcreteAllowedModel = denyList.find(modelId => !modelId.endsWith('/*')); defaultModel = firstConcreteAllowedModel; } } - if (!defaultModel && allowList?.length) { + if (!defaultModel && denyList?.length) { defaultModel = await findFirstAllowedModel(preferredModels); } - if (!defaultModel && allowList?.length) { + if (!defaultModel && denyList?.length) { defaultModel = await findFirstAllowedModelFromDbSnapshot(); } - if (!defaultModel && allowList?.length) { + if (!defaultModel && denyList?.length) { defaultModel = await findFirstAllowedModelFromOpenRouter(); } diff --git a/src/lib/integrations/discord-service.ts b/src/lib/integrations/discord-service.ts index d0909d2a58..ad9de06b29 100644 --- a/src/lib/integrations/discord-service.ts +++ b/src/lib/integrations/discord-service.ts @@ -10,7 +10,7 @@ import { DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN } from '@/l import { APP_URL } from '@/lib/constants'; import { getOrganizationById } from '@/lib/organizations/organizations'; import { getDefaultAllowedModel } from '@/lib/slack-bot/model-allow-list'; -import { createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server'; +import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; import { minimax_m25_free_model } from '@/lib/providers/minimax'; import { CLAUDE_OPUS_CURRENT_MODEL_ID } from '@/lib/providers/anthropic'; @@ -367,9 +367,9 @@ export async function updateModel( if (owner.type === 'org') { const organization = await getOrganizationById(owner.id); if (organization) { - const modelAllowList = organization.settings?.model_allow_list || []; - if (modelAllowList.length > 0) { - const isAllowed = createProviderAwareModelAllowPredicate(modelAllowList); + const modelDenyList = organization.settings?.model_deny_list || []; + if (modelDenyList.length > 0) { + const isAllowed = createAllowPredicateFromDenyList(modelDenyList); if (!(await isAllowed(modelSlug))) { return { success: false, error: 'Model is not allowed by organization policy' }; } diff --git a/src/lib/integrations/slack-service.ts b/src/lib/integrations/slack-service.ts index c5e157eaf5..27a177ae1c 100644 --- a/src/lib/integrations/slack-service.ts +++ b/src/lib/integrations/slack-service.ts @@ -12,7 +12,7 @@ import { WebClient } from '@slack/web-api'; import type { OAuthV2Response } from '@slack/oauth'; import { getOrganizationById } from '@/lib/organizations/organizations'; import { getDefaultAllowedModel } from '@/lib/slack-bot/model-allow-list'; -import { createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server'; +import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; import { minimax_m25_free_model } from '@/lib/providers/minimax'; import { CLAUDE_OPUS_CURRENT_MODEL_ID } from '@/lib/providers/anthropic'; @@ -481,9 +481,9 @@ export async function updateModel( if (owner.type === 'org') { const organization = await getOrganizationById(owner.id); if (organization) { - const modelAllowList = organization.settings?.model_allow_list || []; - if (modelAllowList.length > 0) { - const isAllowed = createProviderAwareModelAllowPredicate(modelAllowList); + const modelDenyList = organization.settings?.model_deny_list || []; + if (modelDenyList.length > 0) { + const isAllowed = createAllowPredicateFromDenyList(modelDenyList); if (!(await isAllowed(modelSlug))) { return { success: false, error: 'Model is not allowed by organization policy' }; } diff --git a/src/lib/model-allow.client.ts b/src/lib/model-allow.client.ts index ca9b29dd6f..33f15f69e8 100644 --- a/src/lib/model-allow.client.ts +++ b/src/lib/model-allow.client.ts @@ -50,6 +50,7 @@ function getOrBuildModelProvidersIndex( /** * Client-safe allow-list evaluation that mirrors * [`createProviderAwareModelAllowPredicate()`](src/lib/model-allow.server.ts:12). + * @deprecated */ export function isModelAllowedProviderAwareClient( modelId: string, diff --git a/src/lib/model-allow.server.ts b/src/lib/model-allow.server.ts index 3010e396cf..6a55de8e69 100644 --- a/src/lib/model-allow.server.ts +++ b/src/lib/model-allow.server.ts @@ -19,6 +19,7 @@ type ProviderAwareAllowPredicateOptions = { export type ProviderAwareAllowPredicate = (modelId: string) => Promise; +/** @deprecated Use `createAllowPredicateFromDenyList` instead */ export function createProviderAwareModelAllowPredicate( allowList: string[], options?: ProviderAwareAllowPredicateOptions @@ -48,6 +49,16 @@ export function createProviderAwareModelAllowPredicate( }; } +export function createAllowPredicateFromDenyList( + denyList: string[] | undefined +): ProviderAwareAllowPredicate { + const denyListSet = new Set(denyList); + return (modelId: string): Promise => { + const normalizedModelId = normalizeModelId(modelId); + return Promise.resolve(!denyListSet.has(normalizedModelId)); + }; +} + export async function createDenyLists( model_allow_list: string[] | undefined, provider_allow_list: string[] | undefined diff --git a/src/lib/slack-bot/model-allow-list.ts b/src/lib/slack-bot/model-allow-list.ts index f75d2d5656..54210cd4e2 100644 --- a/src/lib/slack-bot/model-allow-list.ts +++ b/src/lib/slack-bot/model-allow-list.ts @@ -1,6 +1,6 @@ import { PRIMARY_DEFAULT_MODEL, preferredModels } from '@/lib/models'; import { getOrganizationById } from '@/lib/organizations/organizations'; -import { createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server'; +import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; /** * Get a default model that is allowed for an organization. @@ -15,14 +15,14 @@ export async function getDefaultAllowedModel( return globalDefault; } - const modelAllowList = organization.settings?.model_allow_list || []; + const modelDenyList = organization.settings?.model_deny_list || []; // If no restrictions, use global default - if (modelAllowList.length === 0) { + if (modelDenyList.length === 0) { return globalDefault; } - const isAllowed = createProviderAwareModelAllowPredicate(modelAllowList); + const isAllowed = createAllowPredicateFromDenyList(modelDenyList); // Check if the organization's default model is allowed const orgDefaultModel = organization.settings?.default_model; @@ -42,7 +42,7 @@ export async function getDefaultAllowedModel( } // Fall back to the first non-wildcard model in the allow list - const firstNonWildcard = modelAllowList.find(m => !m.endsWith('/*')); + const firstNonWildcard = modelDenyList.find(m => !m.endsWith('/*')); if (firstNonWildcard) { return firstNonWildcard; } @@ -50,7 +50,7 @@ export async function getDefaultAllowedModel( // If only wildcards, fall back to global default (admin misconfiguration) console.warn( '[SlackBot] Organization has only wildcard entries in model allow list:', - modelAllowList + modelDenyList ); return globalDefault; } diff --git a/src/routers/organizations/organization-settings-router.ts b/src/routers/organizations/organization-settings-router.ts index dc8f09e81f..7dcafc4d10 100644 --- a/src/routers/organizations/organization-settings-router.ts +++ b/src/routers/organizations/organization-settings-router.ts @@ -15,7 +15,7 @@ import * as z from 'zod'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; import { getEnhancedOpenRouterModels } from '@/lib/providers/openrouter'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; -import { createDenyLists, createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server'; +import { createAllowPredicateFromDenyList, createDenyLists } from '@/lib/model-allow.server'; import { KILO_ORGANIZATION_ID } from '@/lib/organizations/constants'; import { listAvailableCustomLlms } from '@/lib/custom-llm/listAvailableCustomLlms'; @@ -158,17 +158,17 @@ export const organizationsSettingsRouter = createTRPCRouter({ }); } - let allowedModels: string[] | undefined; + let deniedModels: string[] | undefined; - if (organization.plan === 'enterprise' && organization?.settings?.model_allow_list) { - allowedModels = organization.settings.model_allow_list; + if (organization.plan === 'enterprise' && organization?.settings?.model_deny_list) { + deniedModels = organization.settings.model_deny_list; } const responseData = await getEnhancedOpenRouterModels(); let filteredModels = responseData.data; - if (allowedModels) { - const isAllowed = createProviderAwareModelAllowPredicate(allowedModels); + if (deniedModels) { + const isAllowed = createAllowPredicateFromDenyList(deniedModels); const models: OpenRouterModel[] = []; for (const model of responseData.data) { if (await isAllowed(model.id)) { @@ -234,11 +234,11 @@ export const organizationsSettingsRouter = createTRPCRouter({ // Check if default_model needs to be cleared if ( - model_allow_list !== undefined && + settingsUpdate.model_deny_list !== undefined && currentSettings.default_model && - model_allow_list.length > 0 + settingsUpdate.model_deny_list.length > 0 ) { - const isAllowed = createProviderAwareModelAllowPredicate(model_allow_list); + const isAllowed = createAllowPredicateFromDenyList(settingsUpdate.model_deny_list); if (!(await isAllowed(currentSettings.default_model))) { // Clear default_model if it's no longer in the allow list @@ -287,9 +287,9 @@ export const organizationsSettingsRouter = createTRPCRouter({ } // Validate default_model against existing model_allow_list - const existingAllowedModels = existingOrg.settings?.model_allow_list; - if (existingAllowedModels && existingAllowedModels.length > 0) { - const isAllowed = createProviderAwareModelAllowPredicate(existingAllowedModels); + const existingDeniedModels = existingOrg.settings?.model_deny_list; + if (existingDeniedModels && existingDeniedModels.length > 0) { + const isAllowed = createAllowPredicateFromDenyList(existingDeniedModels); if (default_model && !(await isAllowed(default_model))) { throw new TRPCError({ From 1cf6fd06bc26e658846d30351d5db7000d992565 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Tue, 3 Mar 2026 16:26:56 +0100 Subject: [PATCH 02/14] Remove isModelAllowedProviderAwareClient --- ...ionProvidersAndModelsConfigurationCard.tsx | 7 +- .../providers-and-models/allowLists.domain.ts | 10 +-- .../useOrganizationConfiguration.ts | 9 +- src/lib/model-allow.client.test.ts | 40 --------- src/lib/model-allow.client.ts | 83 ------------------- 5 files changed, 9 insertions(+), 140 deletions(-) delete mode 100644 src/lib/model-allow.client.test.ts delete mode 100644 src/lib/model-allow.client.ts diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx index c56915a064..4ae8ebbefe 100644 --- a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx +++ b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx @@ -13,7 +13,7 @@ import { AvailableModelsDialog } from './providers-and-models/AvailableModelsDia import { useOrganizationConfiguration } from './providers-and-models/useOrganizationConfiguration'; import { useOpenRouterModelsAndProviders } from '@/app/api/openrouter/hooks'; import type { ProviderSelection } from '@/components/models/util'; -import { isModelAllowedProviderAwareClient } from '@/lib/model-allow.client'; +import { normalizeModelId } from '@/lib/model-utils'; type OrganizationProvidersAndModelsConfigurationCardProps = { organizationId: string; @@ -67,8 +67,7 @@ export function computeProviderSelectionsForSummaryCard(params: { .filter(model => { if (!model.endpoint) return false; return ( - modelAllowList.length === 0 || - isModelAllowedProviderAwareClient(model.slug, modelAllowList, openRouterProviders) + modelAllowList.length === 0 || modelAllowList.includes(normalizeModelId(model.slug)) ); }) .map(model => model.slug); @@ -86,7 +85,7 @@ export function computeProviderSelectionsForSummaryCard(params: { const selectedModels = provider.models .filter(model => { if (!model.endpoint) return false; - return isModelAllowedProviderAwareClient(model.slug, modelAllowList, openRouterProviders); + return modelAllowList.includes(normalizeModelId(model.slug)); }) .map(model => model.slug); diff --git a/src/components/organizations/providers-and-models/allowLists.domain.ts b/src/components/organizations/providers-and-models/allowLists.domain.ts index c6445c9565..a2be6d1f94 100644 --- a/src/components/organizations/providers-and-models/allowLists.domain.ts +++ b/src/components/organizations/providers-and-models/allowLists.domain.ts @@ -1,4 +1,3 @@ -import { isModelAllowedProviderAwareClient } from '@/lib/model-allow.client'; import { normalizeModelId } from '@/lib/model-utils'; export type OpenRouterModelSlugSnapshot = { @@ -93,7 +92,7 @@ export function computeEnabledProviderSlugs( export function computeAllowedModelIds( draftModelAllowList: ReadonlyArray, openRouterModels: ReadonlyArray, - openRouterProviders: OpenRouterProviderModelsSnapshot + _openRouterProviders: OpenRouterProviderModelsSnapshot ): Set { const allowed = new Set(); @@ -107,12 +106,7 @@ export function computeAllowedModelIds( const allowListArray = [...draftModelAllowList]; for (const model of openRouterModels) { const normalizedModelId = normalizeModelId(model.slug); - const isAllowed = isModelAllowedProviderAwareClient( - normalizedModelId, - allowListArray, - openRouterProviders - ); - if (isAllowed) { + if (allowListArray.includes(normalizedModelId)) { allowed.add(normalizedModelId); } } diff --git a/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts b/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts index 60aa46778e..85009b8452 100644 --- a/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts +++ b/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts @@ -7,7 +7,7 @@ import { useOpenRouterProviders, } from '@/app/api/openrouter/hooks'; import type { OpenRouterProvider } from '@/lib/organizations/organization-types'; -import { isModelAllowedProviderAwareClient } from '@/lib/model-allow.client'; +import { normalizeModelId } from '@/lib/model-utils'; export type ConfigurationData = { allModelsSelected: boolean; @@ -23,8 +23,7 @@ export function useOrganizationConfiguration(organizationId: string) { useOrganizationWithMembers(organizationId); const { data: modelsData, isLoading: modelsLoading } = useOpenRouterModels(); const { data: providersData, isLoading: providersLoading } = useOpenRouterProviders(); - const { providers: openRouterProviders, isLoading: providersSnapshotLoading } = - useOpenRouterModelsAndProviders(); + const { isLoading: providersSnapshotLoading } = useOpenRouterModelsAndProviders(); const isLoading = orgLoading || modelsLoading || providersLoading || providersSnapshotLoading; @@ -55,14 +54,14 @@ export function useOrganizationConfiguration(organizationId: string) { displayModelAllowList = []; // No exclusions } else { const allowedModelCount = allModelIds.filter(modelId => - isModelAllowedProviderAwareClient(modelId, savedModelAllowList, openRouterProviders) + savedModelAllowList.includes(normalizeModelId(modelId)) ).length; const modelAllowRatio = allowedModelCount / allModelIds.length; // If more than 50% are allowed, treat as "all selected" mode with exclusions if (modelAllowRatio > 0.5) { allModelsSelected = true; displayModelAllowList = allModelIds.filter( - id => !isModelAllowedProviderAwareClient(id, savedModelAllowList, openRouterProviders) + id => !savedModelAllowList.includes(normalizeModelId(id)) ); } else { allModelsSelected = false; diff --git a/src/lib/model-allow.client.test.ts b/src/lib/model-allow.client.test.ts deleted file mode 100644 index df921560e2..0000000000 --- a/src/lib/model-allow.client.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from '@jest/globals'; -import { isModelAllowedProviderAwareClient } from '@/lib/model-allow.client'; - -describe('isModelAllowedProviderAwareClient', () => { - test('provider-membership wildcard allows model even when model namespace differs', () => { - const openRouterProviders = [ - { - slug: 'cerebras', - models: [{ slug: 'z-ai/glm4.6', endpoint: {} }], - }, - ]; - - expect( - isModelAllowedProviderAwareClient('z-ai/glm4.6', ['cerebras/*'], openRouterProviders) - ).toBe(true); - expect( - isModelAllowedProviderAwareClient('openai/gpt-5.2', ['cerebras/*'], openRouterProviders) - ).toBe(false); - }); - - test('keeps exact + namespace wildcard behavior (including :free normalization)', () => { - const openRouterProviders = [ - { - slug: 'openai', - models: [{ slug: 'openai/gpt-4.1', endpoint: {} }], - }, - ]; - - expect( - isModelAllowedProviderAwareClient('openai/gpt-4.1:free', ['openai/*'], openRouterProviders) - ).toBe(true); - expect( - isModelAllowedProviderAwareClient( - 'openai/gpt-4.1:free', - ['openai/gpt-4.1'], - openRouterProviders - ) - ).toBe(true); - }); -}); diff --git a/src/lib/model-allow.client.ts b/src/lib/model-allow.client.ts deleted file mode 100644 index 33f15f69e8..0000000000 --- a/src/lib/model-allow.client.ts +++ /dev/null @@ -1,83 +0,0 @@ -import 'client-only'; - -import { normalizeModelId } from '@/lib/model-utils'; -import { - isAllowedByExactOrNamespaceWildcard, - isAllowedByProviderMembershipWildcard, - prepareModelAllowList, -} from '@/lib/model-allow.shared'; - -export type OpenRouterProviderModelsSnapshot = Array<{ - slug: string; - models: Array<{ - slug: string; - endpoint?: unknown; - }>; -}>; - -type ModelProvidersIndex = Map>; - -const modelProvidersIndexCache = new WeakMap< - ReadonlyArray<{ slug: string; models: ReadonlyArray<{ slug: string }> }>, - ModelProvidersIndex ->(); - -function getOrBuildModelProvidersIndex( - openRouterProviders: OpenRouterProviderModelsSnapshot -): ModelProvidersIndex { - const cached = modelProvidersIndexCache.get(openRouterProviders); - if (cached) { - return cached; - } - - const index: ModelProvidersIndex = new Map(); - for (const provider of openRouterProviders) { - for (const model of provider.models) { - const normalizedModelId = normalizeModelId(model.slug); - const providersForModel = index.get(normalizedModelId); - if (providersForModel) { - providersForModel.add(provider.slug); - } else { - index.set(normalizedModelId, new Set([provider.slug])); - } - } - } - - modelProvidersIndexCache.set(openRouterProviders, index); - return index; -} - -/** - * Client-safe allow-list evaluation that mirrors - * [`createProviderAwareModelAllowPredicate()`](src/lib/model-allow.server.ts:12). - * @deprecated - */ -export function isModelAllowedProviderAwareClient( - modelId: string, - allowList: string[], - openRouterProviders: OpenRouterProviderModelsSnapshot -): boolean { - if (allowList.length === 0) { - return true; - } - - const normalizedModelId = normalizeModelId(modelId); - - const { allowListSet, wildcardProviderSlugs } = prepareModelAllowList(allowList); - - if (isAllowedByExactOrNamespaceWildcard(normalizedModelId, allowListSet)) { - return true; - } - - // 3) Provider-membership wildcard match - if (wildcardProviderSlugs.size === 0) { - return false; - } - - const modelProvidersIndex = getOrBuildModelProvidersIndex(openRouterProviders); - const providersForModel = modelProvidersIndex.get(normalizedModelId); - return isAllowedByProviderMembershipWildcard( - providersForModel || new Set(), - wildcardProviderSlugs - ); -} From 3407a003b8f5105205c0f3ad30ef5a048d61e75e Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 10:56:13 +0100 Subject: [PATCH 03/14] update comments --- packages/db/src/schema-types.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 8e14fc1a24..67c3c16fc4 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -125,12 +125,11 @@ export const OrganizationPlanSchema = z.enum(['teams', 'enterprise']); export type OrganizationPlan = z.infer; const OrganizationSettingsSchema = z.object({ - /** @deprecated */ + /** @deprecated use model_deny_list instead. delete if this is still here May 2026 */ model_allow_list: z.array(z.string()).optional(), - /** @deprecated */ + /** @deprecated use provider_deny_list instead. delete if this is still here May 2026 */ provider_allow_list: z.array(z.string()).optional(), - // under development, not yet enforced, will replace model_allow_list and provider_allow_list: model_deny_list: z.array(z.string()).optional(), provider_deny_list: z.array(z.string()).optional(), From 39dda1812abb0ee3445303bb46646cf2146bf399 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 10:59:39 +0100 Subject: [PATCH 04/14] Remove dubious check --- .../organizations/providers-and-models/DefaultModelDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/organizations/providers-and-models/DefaultModelDialog.tsx b/src/components/organizations/providers-and-models/DefaultModelDialog.tsx index 490ef27e0f..62fa045160 100644 --- a/src/components/organizations/providers-and-models/DefaultModelDialog.tsx +++ b/src/components/organizations/providers-and-models/DefaultModelDialog.tsx @@ -161,7 +161,7 @@ export function DefaultModelDialog({ - {availableModels.length === 0 && organizationSettings?.model_allow_list && ( + {availableModels.length === 0 && (
No models available. Configure model access first.
From 0939d4d3d5368b9de88ea343687863b97fac365b Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 11:00:09 +0100 Subject: [PATCH 05/14] Delete script --- src/scripts/orgs/fill-deny-lists.ts | 49 ----------------------------- 1 file changed, 49 deletions(-) delete mode 100644 src/scripts/orgs/fill-deny-lists.ts diff --git a/src/scripts/orgs/fill-deny-lists.ts b/src/scripts/orgs/fill-deny-lists.ts deleted file mode 100644 index 4678ae5ac1..0000000000 --- a/src/scripts/orgs/fill-deny-lists.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { db } from '@/lib/drizzle'; -import { createDenyLists } from '@/lib/model-allow.server'; -import { organizations } from '@kilocode/db'; -import { desc, eq, or, sql } from 'drizzle-orm'; - -export async function run() { - const rows = await db - .select({ id: organizations.id }) - .from(organizations) - .where( - or( - sql`${organizations.settings} ->> 'model_allow_list' is not null`, - sql`${organizations.settings} ->> 'provider_allow_list' is not null` - ) - ) - .orderBy(desc(organizations.created_at)); - console.log(`Updating ${rows.length} organizations`); - for (const org of rows) { - await db.transaction(async tran => { - const [{ settings }] = await tran - .select({ settings: organizations.settings }) - .from(organizations) - .where(eq(organizations.id, org.id)); - - if (settings.model_allow_list) { - settings.model_allow_list = [...new Set(settings.model_allow_list)]; - } - if (settings.provider_allow_list) { - settings.provider_allow_list = [...new Set(settings.provider_allow_list)]; - } - - const denyLists = await createDenyLists( - settings.model_allow_list, - settings.provider_allow_list - ); - - settings.model_deny_list = denyLists?.model_deny_list; - settings.provider_deny_list = denyLists?.provider_deny_list; - - await tran - .update(organizations) - .set({ settings }) - .where(eq(organizations.id, org.id)) - .execute(); - - console.log(`Commit ${org.id}`); - }); - } -} From f2c3fe2ea23c57119d5b2d089b2a83747375abbf Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 11:34:51 +0100 Subject: [PATCH 06/14] Implement provider deny list in chat completions route --- src/lib/llm-proxy-helpers.ts | 20 +++----------------- src/lib/models.ts | 4 ---- src/lib/providers/index.ts | 6 +++++- src/lib/providers/openrouter/types.ts | 1 + src/lib/providers/vercel/index.ts | 7 +++++++ 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/lib/llm-proxy-helpers.ts b/src/lib/llm-proxy-helpers.ts index dedae12925..d9650e60f1 100644 --- a/src/lib/llm-proxy-helpers.ts +++ b/src/lib/llm-proxy-helpers.ts @@ -20,7 +20,6 @@ import type { OrganizationPlan, } from '@/lib/organizations/organization-types'; import type { OpenRouterProviderConfig } from '@/lib/providers/openrouter/types'; -import { extraRequiredProviders } from '@/lib/models'; import { getFraudDetectionHeaders } from '@/lib/utils'; import { normalizeProjectId } from '@/lib/normalizeProjectId'; import { getXKiloCodeVersionNumber } from '@/lib/userAgent'; @@ -315,26 +314,13 @@ export function checkOrganizationModelRestrictions(params: { } } - const providerAllowList = params.settings.provider_allow_list || []; + const providerDenyList = params.settings.provider_deny_list; const dataCollection = params.settings.data_collection; const providerConfig: OpenRouterProviderConfig = {}; - if (params.organizationPlan === 'enterprise' && providerAllowList.length > 0) { - // Check if the model requires specific providers that aren't in the allow list - const requiredProviders = extraRequiredProviders(normalizedModelId); - if ( - requiredProviders.length > 0 && - !requiredProviders.every(p => providerAllowList.includes(p)) - ) { - console.error( - `This FREE model requires ALL of these providers to be allowed: ${requiredProviders.join(', ')}` - ); - // This is overly strict, but checking for just one of them is not enough, - // because this list overrides the org allow list - return { error: modelNotAllowedResponse() }; - } - providerConfig.only = providerAllowList; + if (params.organizationPlan === 'enterprise') { + providerConfig.ignore = providerDenyList; } // Setting this only if it's set as an override on the organization settings diff --git a/src/lib/models.ts b/src/lib/models.ts index 55176a8fcc..8be3b826ba 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -80,10 +80,6 @@ function isOpenRouterStealthModel(model: string): boolean { return model.startsWith('openrouter/') && (model.endsWith('-alpha') || model.endsWith('-beta')); } -export function extraRequiredProviders(model: string) { - return kiloFreeModels.find(m => m.public_id === model)?.inference_providers ?? []; -} - export function isDeadFreeModel(model: string): boolean { return !!kiloFreeModels.find(m => m.public_id === model && !m.is_enabled); } diff --git a/src/lib/providers/index.ts b/src/lib/providers/index.ts index 7e9ef2bc29..42c863cb3c 100644 --- a/src/lib/providers/index.ts +++ b/src/lib/providers/index.ts @@ -240,7 +240,11 @@ export function applyProviderSpecificLogic( if (kiloFreeModel) { requestToMutate.model = kiloFreeModel.internal_id; if (kiloFreeModel.inference_providers.length > 0) { - requestToMutate.provider = { only: kiloFreeModel.inference_providers }; + if (requestToMutate.provider) { + requestToMutate.provider.only = kiloFreeModel.inference_providers; + } else { + requestToMutate.provider = { only: kiloFreeModel.inference_providers }; + } } } diff --git a/src/lib/providers/openrouter/types.ts b/src/lib/providers/openrouter/types.ts index 4e76c3f6b8..9d15965853 100644 --- a/src/lib/providers/openrouter/types.ts +++ b/src/lib/providers/openrouter/types.ts @@ -10,6 +10,7 @@ import type { AwsCredentials } from '@/lib/providers/openrouter/inference-provid export type OpenRouterProviderConfig = { order?: string[]; only?: string[]; + ignore?: string[]; data_collection?: 'allow' | 'deny'; zdr?: boolean; }; diff --git a/src/lib/providers/vercel/index.ts b/src/lib/providers/vercel/index.ts index 309056327d..5cee3d09a2 100644 --- a/src/lib/providers/vercel/index.ts +++ b/src/lib/providers/vercel/index.ts @@ -63,6 +63,13 @@ export async function shouldRouteToVercel( return false; } + if ((request.provider?.ignore?.length ?? 0) > 0) { + console.debug( + `[shouldRouteToVercel] not routing to Vercel because provider.ignore is not supported` + ); + return false; + } + if (!isLikelyAvailableOnAllGateways(requestedModel)) { console.debug(`[shouldRouteToVercel] model not available on all gateways`); return false; From dccfdb502f9c8241a82a065825b21a54bffba9b6 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 12:02:31 +0100 Subject: [PATCH 07/14] Vibes --- .../organizations/[id]/defaults/route.test.ts | 35 ++-- .../api/organizations/[id]/defaults/route.ts | 42 ++-- ...rovidersAndModelsConfigurationCard.test.ts | 82 ++++++-- ...ionProvidersAndModelsConfigurationCard.tsx | 88 +++----- .../OrganizationProvidersAndModelsPage.tsx | 28 +-- .../ProviderDetailsDialog.tsx | 40 +--- .../allowLists.domain.test.ts | 90 ++++---- .../providers-and-models/allowLists.domain.ts | 198 ++---------------- .../useOrganizationConfiguration.ts | 70 ++----- ...eProvidersAndModelsAllowListsState.test.ts | 21 +- .../useProvidersAndModelsAllowListsState.ts | 188 ++++++----------- src/lib/llm-proxy-helpers.test.ts | 138 ++++-------- src/lib/llm-proxy-helpers.ts | 20 +- src/lib/model-allow.server.test.ts | 74 ------- src/lib/model-allow.server.ts | 78 ------- src/lib/model-allow.shared.ts | 55 ----- .../organizations/organization-usage.test.ts | 4 +- .../organization-settings-router.test.ts | 180 +++++++--------- .../organization-settings-router.ts | 62 +++--- 19 files changed, 410 insertions(+), 1083 deletions(-) delete mode 100644 src/lib/model-allow.server.test.ts delete mode 100644 src/lib/model-allow.shared.ts diff --git a/src/app/api/organizations/[id]/defaults/route.test.ts b/src/app/api/organizations/[id]/defaults/route.test.ts index 8549e25199..9f20a96e88 100644 --- a/src/app/api/organizations/[id]/defaults/route.test.ts +++ b/src/app/api/organizations/[id]/defaults/route.test.ts @@ -8,6 +8,7 @@ import { createOrganization } from '@/lib/organizations/organizations'; import { db } from '@/lib/drizzle'; import { kilocode_users, organization_memberships, organizations } from '@kilocode/db/schema'; import type { OpenRouterModel } from '@/lib/organizations/organization-types'; +import { PRIMARY_DEFAULT_MODEL } from '@/lib/models'; jest.mock('@/lib/organizations/organization-auth'); jest.mock('@/lib/providers/openrouter'); @@ -57,7 +58,7 @@ describe('GET /api/organizations/[id]/defaults', () => { await db.delete(kilocode_users); }); - test('wildcard-only allow list returns an allowed preferred model (does not fall back to a disallowed global default)', async () => { + test('no deny list returns PRIMARY_DEFAULT_MODEL without calling OpenRouter', async () => { const user = await insertTestUser(); const organization = await createOrganization('Test Org', user.id); @@ -69,9 +70,7 @@ describe('GET /api/organizations/[id]/defaults', () => { user: { ...user, role: 'owner' }, organization: { ...organization, - settings: { - model_allow_list: ['openai/*'], - }, + settings: {}, }, }, }); @@ -82,20 +81,19 @@ describe('GET /api/organizations/[id]/defaults', () => { expect(response.status).toBe(200); const body = await response.json(); - - // The response must be a concrete model id (not the disallowed global default). - expect(body.defaultModel).toMatch(/^openai\//); + expect(body.defaultModel).toBe(PRIMARY_DEFAULT_MODEL); expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled(); }); - test('wildcard-only allow list falls back to the first allowed OpenRouter model when no preferred model matches', async () => { + test('deny list blocking PRIMARY_DEFAULT_MODEL falls back to first non-denied model from OpenRouter', async () => { const user = await insertTestUser(); const organization = await createOrganization('Test Org', user.id); mockedGetEnhancedOpenRouterModels.mockResolvedValue({ data: [ + makeOpenRouterModel(PRIMARY_DEFAULT_MODEL), + makeOpenRouterModel('openai/gpt-4o'), makeOpenRouterModel('example-provider/model-1'), - makeOpenRouterModel('some-other/model-2'), ], }); @@ -106,7 +104,7 @@ describe('GET /api/organizations/[id]/defaults', () => { organization: { ...organization, settings: { - model_allow_list: ['example-provider/*'], + model_deny_list: [PRIMARY_DEFAULT_MODEL], }, }, }, @@ -118,16 +116,15 @@ describe('GET /api/organizations/[id]/defaults', () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.defaultModel).toBe('example-provider/model-1'); - expect(mockedGetEnhancedOpenRouterModels).toHaveBeenCalledTimes(1); + // Should return the first non-denied model from OpenRouter + expect(body.defaultModel).toBe('openai/gpt-4o'); }); - test('falls back to the first concrete allow-list entry when no global default is allowed', async () => { + test('org-configured default model is returned when not in deny list', async () => { const user = await insertTestUser(); const organization = await createOrganization('Test Org', user.id); mockedGetEnhancedOpenRouterModels.mockRejectedValue(new Error('should not be called')); - mockedGetAuthorizedOrgContext.mockResolvedValue({ success: true, data: { @@ -135,7 +132,8 @@ describe('GET /api/organizations/[id]/defaults', () => { organization: { ...organization, settings: { - model_allow_list: ['openai/gpt-5.2', 'openai/*'], + default_model: 'openai/gpt-4o', + model_deny_list: ['anthropic/claude-3-opus'], }, }, }, @@ -147,11 +145,11 @@ describe('GET /api/organizations/[id]/defaults', () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.defaultModel).toBe('openai/gpt-5.2'); + expect(body.defaultModel).toBe('openai/gpt-4o'); expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled(); }); - test('returns 409 when allow list exists but no models are allowed by it', async () => { + test('returns 409 when all models are denied', async () => { const user = await insertTestUser(); const organization = await createOrganization('Test Org', user.id); @@ -164,7 +162,7 @@ describe('GET /api/organizations/[id]/defaults', () => { organization: { ...organization, settings: { - model_allow_list: ['no-such-provider/*'], + model_deny_list: [PRIMARY_DEFAULT_MODEL], }, }, }, @@ -179,6 +177,5 @@ describe('GET /api/organizations/[id]/defaults', () => { expect(body).toEqual({ error: "No valid models are allowed by this organization's allow list.", }); - expect(mockedGetEnhancedOpenRouterModels).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/api/organizations/[id]/defaults/route.ts b/src/app/api/organizations/[id]/defaults/route.ts index a5d971657b..9559c1d432 100644 --- a/src/app/api/organizations/[id]/defaults/route.ts +++ b/src/app/api/organizations/[id]/defaults/route.ts @@ -70,34 +70,26 @@ export async function GET( // Fallback to global default if no organization default is set or it's not allowed if (!defaultModel) { - defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL]); - - if (!defaultModel) { - if (!denyList?.length) { - defaultModel = PRIMARY_DEFAULT_MODEL; - } else { - const firstConcreteAllowedModel = denyList.find(modelId => !modelId.endsWith('/*')); - defaultModel = firstConcreteAllowedModel; + if (!denyList?.length) { + // No restrictions - use PRIMARY_DEFAULT_MODEL directly + defaultModel = PRIMARY_DEFAULT_MODEL; + } else { + defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL, ...preferredModels]); + + if (!defaultModel) { + defaultModel = await findFirstAllowedModelFromDbSnapshot(); } - } - - if (!defaultModel && denyList?.length) { - defaultModel = await findFirstAllowedModel(preferredModels); - } - - if (!defaultModel && denyList?.length) { - defaultModel = await findFirstAllowedModelFromDbSnapshot(); - } - if (!defaultModel && denyList?.length) { - defaultModel = await findFirstAllowedModelFromOpenRouter(); - } + if (!defaultModel) { + defaultModel = await findFirstAllowedModelFromOpenRouter(); + } - if (!defaultModel) { - return NextResponse.json( - { error: "No valid models are allowed by this organization's allow list." }, - { status: 409 } - ); + if (!defaultModel) { + return NextResponse.json( + { error: "No valid models are allowed by this organization's allow list." }, + { status: 409 } + ); + } } } diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts index c4bae81712..01e9a581cf 100644 --- a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts +++ b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts @@ -2,33 +2,53 @@ import { describe, test, expect } from '@jest/globals'; import { computeProviderSelectionsForSummaryCard } from './OrganizationProvidersAndModelsConfigurationCard'; describe('computeProviderSelectionsForSummaryCard', () => { - test('expands provider wildcard entries (e.g., anthropic/*) when provider is allowed', () => { + test('both deny lists empty returns null (all providers and models)', () => { const openRouterProviders = [ { slug: 'anthropic', models: [ { slug: 'anthropic/claude-3-opus', endpoint: 'chat' }, { slug: 'anthropic/claude-3-sonnet', endpoint: 'chat' }, - { slug: 'anthropic/disabled-model' }, ], }, ]; const selections = computeProviderSelectionsForSummaryCard({ openRouterProviders, - providerAllowList: ['anthropic'], - modelAllowList: ['anthropic/*'], + providerDenyList: [], + modelDenyList: [], + }); + + expect(selections).toBeNull(); + }); + + test('providerDenyList excludes denied providers', () => { + const openRouterProviders = [ + { + slug: 'openai', + models: [{ slug: 'openai/gpt-4', endpoint: 'chat' }], + }, + { + slug: 'anthropic', + models: [{ slug: 'anthropic/claude-3-opus', endpoint: 'chat' }], + }, + ]; + + const selections = computeProviderSelectionsForSummaryCard({ + openRouterProviders, + providerDenyList: ['openai'], + modelDenyList: [], }); expect(selections).toEqual([ { slug: 'anthropic', - models: ['anthropic/claude-3-opus', 'anthropic/claude-3-sonnet'], + models: ['anthropic/claude-3-opus'], }, ]); }); - test('keeps existing exact-match behavior for model allow list entries', () => { + test('modelDenyList excludes denied models', () => { const openRouterProviders = [ { slug: 'anthropic', @@ -41,23 +61,23 @@ describe('computeProviderSelectionsForSummaryCard', () => { const selections = computeProviderSelectionsForSummaryCard({ openRouterProviders, - providerAllowList: ['anthropic'], - modelAllowList: ['anthropic/claude-3-opus'], + providerDenyList: [], + modelDenyList: ['anthropic/claude-3-opus'], }); expect(selections).toEqual([ { slug: 'anthropic', - models: ['anthropic/claude-3-opus'], + models: ['anthropic/claude-3-sonnet'], }, ]); }); - test('supports wildcard-only model allow lists even when provider allow list is empty', () => { + test('combined deny lists exclude both providers and models', () => { const openRouterProviders = [ { slug: 'openai', - models: [{ slug: 'openai/gpt-4.1', endpoint: 'chat' }], + models: [{ slug: 'openai/gpt-4', endpoint: 'chat' }], }, { slug: 'anthropic', @@ -70,37 +90,53 @@ describe('computeProviderSelectionsForSummaryCard', () => { const selections = computeProviderSelectionsForSummaryCard({ openRouterProviders, - providerAllowList: [], - modelAllowList: ['anthropic/*'], + providerDenyList: ['openai'], + modelDenyList: ['anthropic/claude-3-opus'], }); expect(selections).toEqual([ { slug: 'anthropic', - models: ['anthropic/claude-3-opus', 'anthropic/claude-3-sonnet'], + models: ['anthropic/claude-3-sonnet'], }, ]); }); - test('supports provider-membership wildcard when model namespace differs (e.g. cerebras/* allows z-ai/glm4.6)', () => { + test('returns null when all providers are denied', () => { const openRouterProviders = [ { - slug: 'cerebras', - models: [{ slug: 'z-ai/glm4.6', endpoint: 'chat' }], + slug: 'openai', + models: [{ slug: 'openai/gpt-4', endpoint: 'chat' }], }, ]; const selections = computeProviderSelectionsForSummaryCard({ openRouterProviders, - providerAllowList: ['cerebras'], - modelAllowList: ['cerebras/*'], + providerDenyList: ['openai'], + modelDenyList: [], }); - expect(selections).toEqual([ + expect(selections).toBeNull(); + }); + + test('models without endpoint are excluded', () => { + const openRouterProviders = [ { - slug: 'cerebras', - models: ['z-ai/glm4.6'], + slug: 'anthropic', + models: [ + { slug: 'anthropic/claude-3-opus', endpoint: 'chat' }, + { slug: 'anthropic/disabled-model' }, + ], }, - ]); + ]; + + const selections = computeProviderSelectionsForSummaryCard({ + openRouterProviders, + providerDenyList: [], + modelDenyList: ['anthropic/claude-3-opus'], + }); + + // Both models are excluded (one by deny list, one by no endpoint) + expect(selections).toBeNull(); }); }); diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx index 4ae8ebbefe..3153172dc7 100644 --- a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx +++ b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx @@ -30,74 +30,36 @@ type ProviderForSummaryCard = { export function computeProviderSelectionsForSummaryCard(params: { openRouterProviders: ProviderForSummaryCard[]; - providerAllowList: string[]; - modelAllowList: string[]; + providerDenyList: string[]; + modelDenyList: string[]; }): ProviderSelection[] | null { - const { openRouterProviders, providerAllowList, modelAllowList } = params; + const { openRouterProviders, providerDenyList, modelDenyList } = params; + + const providerDenySet = new Set(providerDenyList); + const modelDenySet = new Set(modelDenyList.map(id => normalizeModelId(id))); const selections: ProviderSelection[] = []; - // If both lists are empty, show all providers with all their models - if (providerAllowList.length === 0 && modelAllowList.length === 0) { - for (const provider of openRouterProviders) { - const availableModels = provider.models - .filter(model => model.endpoint) - .map(model => model.slug); - - if (availableModels.length > 0) { - selections.push({ - slug: provider.slug, - models: availableModels, - }); - } - } + for (const provider of openRouterProviders) { + if (providerDenySet.has(provider.slug)) continue; - return selections; - } + const availableModels = provider.models + .filter(model => model.endpoint && !modelDenySet.has(normalizeModelId(model.slug))) + .map(model => model.slug); - // If we have provider allow list, use it to determine which providers are selected - if (providerAllowList.length > 0) { - for (const providerSlug of providerAllowList) { - const provider = openRouterProviders.find(p => p.slug === providerSlug); - if (!provider) continue; - - // If model allow list is empty, include all models for this provider. - // Otherwise, include models allowed by the organization's allow list (supports wildcards). - const selectedModels = provider.models - .filter(model => { - if (!model.endpoint) return false; - return ( - modelAllowList.length === 0 || modelAllowList.includes(normalizeModelId(model.slug)) - ); - }) - .map(model => model.slug); - - if (selectedModels.length > 0) { - selections.push({ - slug: providerSlug, - models: selectedModels, - }); - } - } - } else if (modelAllowList.length > 0) { - // If we only have model allow list, group allowed models by provider. - for (const provider of openRouterProviders) { - const selectedModels = provider.models - .filter(model => { - if (!model.endpoint) return false; - return modelAllowList.includes(normalizeModelId(model.slug)); - }) - .map(model => model.slug); - - if (selectedModels.length > 0) { - selections.push({ - slug: provider.slug, - models: selectedModels, - }); - } + if (availableModels.length > 0) { + selections.push({ + slug: provider.slug, + models: availableModels, + }); } } + // If both deny lists are empty, there are no restrictions + if (providerDenyList.length === 0 && modelDenyList.length === 0) { + return null; + } + return selections.length > 0 ? selections : null; } @@ -118,13 +80,13 @@ export function OrganizationProvidersAndModelsConfigurationCard({ } const settings = organizationData.settings; - const providerAllowList = settings?.provider_allow_list || []; - const modelAllowList = settings?.model_allow_list || []; + const providerDenyList = settings?.provider_deny_list ?? []; + const modelDenyList = settings?.model_deny_list ?? []; return computeProviderSelectionsForSummaryCard({ openRouterProviders, - providerAllowList, - modelAllowList, + providerDenyList, + modelDenyList, }); }, [configurationData, organizationData, openRouterProviders]); diff --git a/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx b/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx index 06112cbc4c..8c16a4dc8c 100644 --- a/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx +++ b/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx @@ -146,8 +146,8 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro if (!organizationData) return; if (state.status === 'ready') return; actions.initFromServer({ - modelAllowList: organizationData.settings?.model_allow_list ?? [], - providerAllowList: organizationData.settings?.provider_allow_list ?? [], + modelDenyList: organizationData.settings?.model_deny_list ?? [], + providerDenyList: organizationData.settings?.provider_deny_list ?? [], }); }, [actions, organizationData, state.status]); @@ -174,14 +174,6 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro [actions, canEdit] ); - const handleToggleAllowFutureModelsForProvider = useCallback( - (providerSlug: string, nextAllowed: boolean) => { - if (!canEdit) return; - actions.toggleProviderWildcard({ providerSlug, nextAllowed }); - }, - [actions, canEdit] - ); - const handleCancelChanges = useCallback(() => { if (!canEdit) return; actions.resetToInitial(); @@ -194,8 +186,8 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro try { await updateOrganizationSettings.mutateAsync({ organizationId, - model_allow_list: state.draftModelAllowList, - provider_allow_list: state.draftProviderAllowList, + model_deny_list: state.draftModelDenyList, + provider_deny_list: state.draftProviderDenyList, }); actions.markSaved(); @@ -414,13 +406,6 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro return rows; }, [infoProvider, openRouterProviders, preferredIndexByModelId]); - const infoProviderAllowsAllModels = useMemo(() => { - if (!infoProvider) return false; - if (state.status !== 'ready') return false; - if (state.draftModelAllowList.length === 0) return false; - return state.draftModelAllowList.includes(`${infoProvider.providerSlug}/*`); - }, [infoProvider, state]); - // NOTE: returns must happen after all hooks above, otherwise React will error // with "Rendered more hooks than during the previous render" as data loads. if (!organizationData) { @@ -529,15 +514,10 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro canEdit={canEdit} infoProvider={infoProvider} enabledProviderSlugs={enabledProviderSlugs} - draftModelAllowListLength={ - state.status === 'ready' ? state.draftModelAllowList.length : 0 - } - infoProviderAllowsAllModels={infoProviderAllowsAllModels} infoProviderModels={infoProviderModels} allowedModelIds={allowedModelIds} formatPriceCompact={formatPriceCompact} onToggleProviderEnabled={handleToggleProviderEnabled} - onToggleAllowFutureModelsForProvider={handleToggleAllowFutureModelsForProvider} onToggleModelAllowed={handleToggleModelAllowed} onClose={() => actions.setInfoProviderSlug(null)} /> diff --git a/src/components/organizations/providers-and-models/ProviderDetailsDialog.tsx b/src/components/organizations/providers-and-models/ProviderDetailsDialog.tsx index 82db42f08a..7b74d4aabe 100644 --- a/src/components/organizations/providers-and-models/ProviderDetailsDialog.tsx +++ b/src/components/organizations/providers-and-models/ProviderDetailsDialog.tsx @@ -26,13 +26,10 @@ export function ProviderDetailsDialog({ canEdit, infoProvider, enabledProviderSlugs, - draftModelAllowListLength, - infoProviderAllowsAllModels, infoProviderModels, allowedModelIds, formatPriceCompact, onToggleProviderEnabled, - onToggleAllowFutureModelsForProvider, onToggleModelAllowed, onClose, }: { @@ -40,27 +37,16 @@ export function ProviderDetailsDialog({ canEdit: boolean; infoProvider: ProviderRow | null; enabledProviderSlugs: ReadonlySet; - draftModelAllowListLength: number; - infoProviderAllowsAllModels: boolean; infoProviderModels: ReadonlyArray; allowedModelIds: ReadonlySet; formatPriceCompact: (raw: string) => string; onToggleProviderEnabled: (providerSlug: string, nextEnabled: boolean) => void; - onToggleAllowFutureModelsForProvider: (providerSlug: string, nextAllowed: boolean) => void; onToggleModelAllowed: (modelId: string, nextAllowed: boolean) => void; onClose: () => void; }) { const isProviderEnabled = infoProvider ? enabledProviderSlugs.has(infoProvider.providerSlug) : false; - const allowAllModelsChecked = - draftModelAllowListLength === 0 ? true : Boolean(infoProvider && infoProviderAllowsAllModels); - - const allowAllModelsDisabled = - !canEdit || - !infoProvider || - draftModelAllowListLength === 0 || - !enabledProviderSlugs.has(infoProvider.providerSlug); return ( (nextOpen ? null : onClose())}> @@ -99,30 +85,6 @@ export function ProviderDetailsDialog({
{infoProvider ? (
-
- -
-
@@ -165,7 +127,7 @@ export function ProviderDetailsDialog({ { onToggleModelAllowed(model.modelId, Boolean(nextChecked)); }} diff --git a/src/components/organizations/providers-and-models/allowLists.domain.test.ts b/src/components/organizations/providers-and-models/allowLists.domain.test.ts index 50cf6fa603..0fb8b8832a 100644 --- a/src/components/organizations/providers-and-models/allowLists.domain.test.ts +++ b/src/components/organizations/providers-and-models/allowLists.domain.test.ts @@ -1,84 +1,76 @@ import { describe, expect, test } from '@jest/globals'; import { - buildModelProvidersIndex, - canonicalizeModelAllowList, + canonicalizeDenyList, computeAllowedModelIds, computeEnabledProviderSlugs, - toggleAllowFutureModelsForProvider, toggleModelAllowed, toggleProviderEnabled, } from '@/components/organizations/providers-and-models/allowLists.domain'; describe('allowLists.domain', () => { - test('`[]` provider_allow_list means all providers enabled', () => { + test('empty provider_deny_list means all providers enabled', () => { const enabled = computeEnabledProviderSlugs([], ['a', 'b']); expect([...enabled].sort()).toEqual(['a', 'b']); }); - test('`[]` model_allow_list means all models allowed (normalized)', () => { + test('non-empty provider_deny_list excludes denied providers', () => { + const enabled = computeEnabledProviderSlugs(['a'], ['a', 'b']); + expect([...enabled].sort()).toEqual(['b']); + }); + + test('empty model_deny_list means all models allowed (normalized)', () => { const openRouterModels = [{ slug: 'openai/gpt-4.1:free' }, { slug: 'openai/gpt-4.1' }]; - const openRouterProviders = [ - { - slug: 'openai', - models: [{ slug: 'openai/gpt-4.1', endpoint: {} }], - }, - ]; - const allowed = computeAllowedModelIds([], openRouterModels, openRouterProviders); + const allowed = computeAllowedModelIds([], openRouterModels); expect([...allowed].sort()).toEqual(['openai/gpt-4.1']); }); - test('canonicalizeModelAllowList normalizes :free and dedupes', () => { - expect(canonicalizeModelAllowList(['openai/gpt-4.1:free', 'openai/gpt-4.1'])).toEqual([ + test('non-empty model_deny_list excludes denied models', () => { + const openRouterModels = [{ slug: 'openai/gpt-4.1' }, { slug: 'anthropic/claude-3-opus' }]; + + const allowed = computeAllowedModelIds(['anthropic/claude-3-opus'], openRouterModels); + expect([...allowed]).toEqual(['openai/gpt-4.1']); + }); + + test('canonicalizeDenyList normalizes :free and dedupes', () => { + expect(canonicalizeDenyList(['openai/gpt-4.1:free', 'openai/gpt-4.1'])).toEqual([ 'openai/gpt-4.1', ]); }); - test('toggleProviderEnabled(disable) removes provider wildcard from model allow list', () => { - const { nextModelAllowList, nextProviderAllowList } = toggleProviderEnabled({ - providerSlug: 'cerebras', + test('toggleProviderEnabled(disable) adds provider to deny list', () => { + const next = toggleProviderEnabled({ + providerSlug: 'openai', nextEnabled: false, - draftProviderAllowList: [], - draftModelAllowList: ['cerebras/*', 'openai/gpt-4.1'], - allProviderSlugsWithEndpoints: ['cerebras', 'openai'], - hadAllProvidersInitially: true, + draftProviderDenyList: [], }); - - expect(nextModelAllowList).toEqual(['openai/gpt-4.1']); - expect(nextProviderAllowList.sort()).toEqual(['openai']); + expect(next).toEqual(['openai']); }); - test('toggleAllowFutureModelsForProvider enables provider and adds provider wildcard', () => { - const { nextModelAllowList, nextProviderAllowList } = toggleAllowFutureModelsForProvider({ - providerSlug: 'cerebras', - nextAllowed: true, - draftModelAllowList: ['openai/gpt-4.1'], - draftProviderAllowList: ['openai'], - allProviderSlugsWithEndpoints: ['cerebras', 'openai'], - hadAllProvidersInitially: false, + test('toggleProviderEnabled(enable) removes provider from deny list', () => { + const next = toggleProviderEnabled({ + providerSlug: 'openai', + nextEnabled: true, + draftProviderDenyList: ['openai', 'anthropic'], }); - - expect(nextModelAllowList.sort()).toEqual(['cerebras/*', 'openai/gpt-4.1']); - expect(nextProviderAllowList.sort()).toEqual(['cerebras', 'openai']); + expect(next).toEqual(['anthropic']); }); - test('toggleModelAllowed(disable) removes provider wildcards for providers offering the model', () => { - const providerIndex = buildModelProvidersIndex([ - { - slug: 'cerebras', - models: [{ slug: 'z-ai/glm4.6', endpoint: {} }], - }, - ]); - + test('toggleModelAllowed(disallow) adds model to deny list', () => { const next = toggleModelAllowed({ - modelId: 'z-ai/glm4.6', + modelId: 'openai/gpt-4.1', nextAllowed: false, - draftModelAllowList: ['cerebras/*', 'z-ai/glm4.6'], - allModelIds: ['z-ai/glm4.6'], - providerSlugsForModelId: [...(providerIndex.get('z-ai/glm4.6') ?? [])], - hadAllModelsInitially: false, + draftModelDenyList: [], }); + expect(next).toEqual(['openai/gpt-4.1']); + }); - expect(next).toEqual([]); + test('toggleModelAllowed(allow) removes model from deny list', () => { + const next = toggleModelAllowed({ + modelId: 'openai/gpt-4.1', + nextAllowed: true, + draftModelDenyList: ['openai/gpt-4.1', 'anthropic/claude-3-opus'], + }); + expect(next).toEqual(['anthropic/claude-3-opus']); }); }); diff --git a/src/components/organizations/providers-and-models/allowLists.domain.ts b/src/components/organizations/providers-and-models/allowLists.domain.ts index a2be6d1f94..52e44503da 100644 --- a/src/components/organizations/providers-and-models/allowLists.domain.ts +++ b/src/components/organizations/providers-and-models/allowLists.domain.ts @@ -24,22 +24,8 @@ export function stringListsEqual(a: ReadonlyArray, b: ReadonlyArray): string[] { - // Empty array is meaningful ("all providers enabled, including future"). - if (raw.length === 0) return []; - return sortUniqueStrings(raw); -} - -export function canonicalizeModelAllowList(raw: ReadonlyArray): string[] { - // Empty array is meaningful ("all models allowed, including future"). - if (raw.length === 0) return []; - - return sortUniqueStrings( - raw.map(entry => { - if (entry.endsWith('/*')) return entry; - return normalizeModelId(entry); - }) - ); +export function canonicalizeDenyList(raw: ReadonlyArray): string[] { + return sortUniqueStrings(raw.map(entry => normalizeModelId(entry))); } export function buildModelProvidersIndex( @@ -71,194 +57,54 @@ export function computeAllProviderSlugsWithEndpoints( } export function computeEnabledProviderSlugs( - draftProviderAllowList: ReadonlyArray, + draftProviderDenyList: ReadonlyArray, allProviderSlugsWithEndpoints: ReadonlyArray ): Set { - if (draftProviderAllowList.length === 0) { - return new Set(allProviderSlugsWithEndpoints); - } - - const allowSet = new Set(draftProviderAllowList); - const enabled = new Set(); - for (const slug of allProviderSlugsWithEndpoints) { - if (allowSet.has(slug)) { - enabled.add(slug); - } - } - - return enabled; + const denySet = new Set(draftProviderDenyList); + return new Set(allProviderSlugsWithEndpoints.filter(slug => !denySet.has(slug))); } export function computeAllowedModelIds( - draftModelAllowList: ReadonlyArray, - openRouterModels: ReadonlyArray, - _openRouterProviders: OpenRouterProviderModelsSnapshot + draftModelDenyList: ReadonlyArray, + openRouterModels: ReadonlyArray ): Set { + const denySet = new Set(draftModelDenyList); const allowed = new Set(); - - if (draftModelAllowList.length === 0) { - for (const model of openRouterModels) { - allowed.add(normalizeModelId(model.slug)); - } - return allowed; - } - - const allowListArray = [...draftModelAllowList]; for (const model of openRouterModels) { const normalizedModelId = normalizeModelId(model.slug); - if (allowListArray.includes(normalizedModelId)) { + if (!denySet.has(normalizedModelId)) { allowed.add(normalizedModelId); } } - return allowed; } export function toggleProviderEnabled(params: { providerSlug: string; nextEnabled: boolean; - draftProviderAllowList: ReadonlyArray; - draftModelAllowList: ReadonlyArray; - allProviderSlugsWithEndpoints: ReadonlyArray; - hadAllProvidersInitially: boolean; -}): { nextProviderAllowList: string[]; nextModelAllowList: string[] } { - const { - providerSlug, - nextEnabled, - draftProviderAllowList, - draftModelAllowList, - allProviderSlugsWithEndpoints, - hadAllProvidersInitially, - } = params; - - let nextModelAllowList = [...draftModelAllowList]; - if (!nextEnabled) { - if (nextModelAllowList.length !== 0) { - nextModelAllowList = nextModelAllowList.filter(entry => entry !== `${providerSlug}/*`); - } - } - nextModelAllowList = canonicalizeModelAllowList(nextModelAllowList); - - if (draftProviderAllowList.length === 0) { - if (nextEnabled) { - return { nextProviderAllowList: [], nextModelAllowList }; - } - - return { - nextProviderAllowList: allProviderSlugsWithEndpoints.filter(slug => slug !== providerSlug), - nextModelAllowList, - }; - } - - const allowSet = new Set(draftProviderAllowList); + draftProviderDenyList: ReadonlyArray; +}): string[] { + const { providerSlug, nextEnabled, draftProviderDenyList } = params; + const denySet = new Set(draftProviderDenyList); if (nextEnabled) { - allowSet.add(providerSlug); + denySet.delete(providerSlug); } else { - allowSet.delete(providerSlug); - } - - const nextProviderAllowList = canonicalizeProviderAllowList([...allowSet]); - if ( - hadAllProvidersInitially && - nextProviderAllowList.length === allProviderSlugsWithEndpoints.length - ) { - return { nextProviderAllowList: [], nextModelAllowList }; + denySet.add(providerSlug); } - - return { nextProviderAllowList, nextModelAllowList }; + return sortUniqueStrings([...denySet]); } export function toggleModelAllowed(params: { modelId: string; nextAllowed: boolean; - draftModelAllowList: ReadonlyArray; - allModelIds: ReadonlyArray; - providerSlugsForModelId: ReadonlyArray | undefined; - hadAllModelsInitially: boolean; + draftModelDenyList: ReadonlyArray; }): string[] { - const { - modelId, - nextAllowed, - draftModelAllowList, - allModelIds, - providerSlugsForModelId, - hadAllModelsInitially, - } = params; - - if (draftModelAllowList.length === 0) { - if (nextAllowed) { - return []; - } - return canonicalizeModelAllowList(allModelIds.filter(id => id !== modelId)); - } - - const allowSet = new Set(draftModelAllowList); - + const { modelId, nextAllowed, draftModelDenyList } = params; + const denySet = new Set(draftModelDenyList); if (nextAllowed) { - allowSet.add(modelId); + denySet.delete(modelId); } else { - // If the model was effectively allowed via one (or more) provider wildcards, - // disabling it forces those wildcards off. - for (const providerSlug of providerSlugsForModelId ?? []) { - allowSet.delete(`${providerSlug}/*`); - } - allowSet.delete(modelId); - } - - const next = canonicalizeModelAllowList([...allowSet]); - if (hadAllModelsInitially && next.length === allModelIds.length) { - return []; + denySet.add(modelId); } - - return next; -} - -export function toggleAllowFutureModelsForProvider(params: { - providerSlug: string; - nextAllowed: boolean; - draftModelAllowList: ReadonlyArray; - draftProviderAllowList: ReadonlyArray; - allProviderSlugsWithEndpoints: ReadonlyArray; - hadAllProvidersInitially: boolean; -}): { nextModelAllowList: string[]; nextProviderAllowList: string[] } { - const { - providerSlug, - nextAllowed, - draftModelAllowList, - draftProviderAllowList, - allProviderSlugsWithEndpoints, - hadAllProvidersInitially, - } = params; - - let nextModelAllowList = [...draftModelAllowList]; - if (nextModelAllowList.length !== 0) { - const wildcardEntry = `${providerSlug}/*`; - const allowSet = new Set(nextModelAllowList); - if (nextAllowed) { - allowSet.add(wildcardEntry); - } else { - allowSet.delete(wildcardEntry); - } - nextModelAllowList = canonicalizeModelAllowList([...allowSet]); - } else { - nextModelAllowList = []; - } - - if (!nextAllowed) { - return { - nextModelAllowList, - nextProviderAllowList: canonicalizeProviderAllowList(draftProviderAllowList), - }; - } - - const { nextProviderAllowList } = toggleProviderEnabled({ - providerSlug, - nextEnabled: true, - draftProviderAllowList, - draftModelAllowList: nextModelAllowList, - allProviderSlugsWithEndpoints, - hadAllProvidersInitially, - }); - - return { nextModelAllowList, nextProviderAllowList }; + return sortUniqueStrings([...denySet]); } diff --git a/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts b/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts index 85009b8452..550fa6ec12 100644 --- a/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts +++ b/src/components/organizations/providers-and-models/useOrganizationConfiguration.ts @@ -7,13 +7,12 @@ import { useOpenRouterProviders, } from '@/app/api/openrouter/hooks'; import type { OpenRouterProvider } from '@/lib/organizations/organization-types'; -import { normalizeModelId } from '@/lib/model-utils'; export type ConfigurationData = { - allModelsSelected: boolean; - allProvidersSelected: boolean; - displayModelAllowList: string[]; - displayProviderAllowList: string[]; + allModelsAllowed: boolean; + allProvidersEnabled: boolean; + displayModelDenyList: string[]; + displayProviderDenyList: string[]; getProviderNames: (slugs: string[]) => string[]; getModelNames: (modelIds: string[]) => string[]; }; @@ -36,56 +35,11 @@ export function useOrganizationConfiguration(organizationId: string) { } const settings = organizationData.settings; - const savedModelAllowList = settings?.model_allow_list || []; - const savedProviderAllowList = settings?.provider_allow_list || []; + const modelDenyList = settings?.model_deny_list ?? []; + const providerDenyList = settings?.provider_deny_list ?? []; - // Use the same dual-mode logic as OrganizationModelSelector - let allModelsSelected = true; - let allProvidersSelected = true; - let displayModelAllowList = savedModelAllowList; - let displayProviderAllowList = savedProviderAllowList; - - const allModelIds = modelsData.data.map(model => model.id); - const allProviderSlugs = providersData.data.map(provider => provider.slug); - - // Empty array means "all selected" for auto-inclusion of new models/providers - if (savedModelAllowList.length === 0) { - allModelsSelected = true; - displayModelAllowList = []; // No exclusions - } else { - const allowedModelCount = allModelIds.filter(modelId => - savedModelAllowList.includes(normalizeModelId(modelId)) - ).length; - const modelAllowRatio = allowedModelCount / allModelIds.length; - // If more than 50% are allowed, treat as "all selected" mode with exclusions - if (modelAllowRatio > 0.5) { - allModelsSelected = true; - displayModelAllowList = allModelIds.filter( - id => !savedModelAllowList.includes(normalizeModelId(id)) - ); - } else { - allModelsSelected = false; - displayModelAllowList = savedModelAllowList; - } - } - - // Empty array means "all selected" for auto-inclusion of new providers - if (savedProviderAllowList.length === 0) { - allProvidersSelected = true; - displayProviderAllowList = []; // No exclusions - } else { - const providerAllowRatio = savedProviderAllowList.length / allProviderSlugs.length; - // If more than 50% are allowed, treat as "all selected" mode with exclusions - if (providerAllowRatio > 0.5) { - allProvidersSelected = true; - displayProviderAllowList = allProviderSlugs.filter( - slug => !savedProviderAllowList.includes(slug) - ); - } else { - allProvidersSelected = false; - displayProviderAllowList = savedProviderAllowList; - } - } + const allModelsAllowed = modelDenyList.length === 0; + const allProvidersEnabled = providerDenyList.length === 0; // Get provider names for display const getProviderNames = (slugs: string[]) => { @@ -102,10 +56,10 @@ export function useOrganizationConfiguration(organizationId: string) { }; const configurationData: ConfigurationData = { - allModelsSelected, - allProvidersSelected, - displayModelAllowList, - displayProviderAllowList, + allModelsAllowed, + allProvidersEnabled, + displayModelDenyList: modelDenyList, + displayProviderDenyList: providerDenyList, getProviderNames, getModelNames, }; diff --git a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts index c74a872243..c7475e0637 100644 --- a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts +++ b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts @@ -11,8 +11,8 @@ describe('providersAndModelsAllowListsReducer', () => { state = providersAndModelsAllowListsReducer(state, { type: 'INIT_FROM_SERVER', - modelAllowList: ['openai/gpt-4.1'], - providerAllowList: [], + modelDenyList: ['openai/gpt-4.1'], + providerDenyList: [], }); if (state.status !== 'ready') { @@ -21,10 +21,8 @@ describe('providersAndModelsAllowListsReducer', () => { state = providersAndModelsAllowListsReducer(state, { type: 'TOGGLE_MODEL', - modelId: 'openai/gpt-4.1', + modelId: 'anthropic/claude-3-opus', nextAllowed: false, - allModelIds: ['openai/gpt-4.1'], - providerSlugsForModelId: ['openai'], }); state = providersAndModelsAllowListsReducer(state, { type: 'RESET_TO_INITIAL' }); @@ -33,8 +31,8 @@ describe('providersAndModelsAllowListsReducer', () => { throw new Error('expected ready state'); } - expect(state.draftModelAllowList).toEqual(state.initialModelAllowList); - expect(state.draftProviderAllowList).toEqual(state.initialProviderAllowList); + expect(state.draftModelDenyList).toEqual(state.initialModelDenyList); + expect(state.draftProviderDenyList).toEqual(state.initialProviderDenyList); }); test('init -> toggle -> mark saved marks clean (draft becomes initial)', () => { @@ -42,8 +40,8 @@ describe('providersAndModelsAllowListsReducer', () => { state = providersAndModelsAllowListsReducer(state, { type: 'INIT_FROM_SERVER', - modelAllowList: [], - providerAllowList: [], + modelDenyList: [], + providerDenyList: [], }); if (state.status !== 'ready') { @@ -54,7 +52,6 @@ describe('providersAndModelsAllowListsReducer', () => { type: 'TOGGLE_PROVIDER', providerSlug: 'openai', nextEnabled: false, - allProviderSlugsWithEndpoints: ['openai', 'anthropic'], }); state = providersAndModelsAllowListsReducer(state, { type: 'MARK_SAVED' }); @@ -63,7 +60,7 @@ describe('providersAndModelsAllowListsReducer', () => { throw new Error('expected ready state'); } - expect(state.initialProviderAllowList).toEqual(state.draftProviderAllowList); - expect(state.initialModelAllowList).toEqual(state.draftModelAllowList); + expect(state.initialProviderDenyList).toEqual(state.draftProviderDenyList); + expect(state.initialModelDenyList).toEqual(state.draftModelDenyList); }); }); diff --git a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts index 8922c271bf..71c6ca72d6 100644 --- a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts +++ b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts @@ -2,14 +2,12 @@ import { useCallback, useMemo, useReducer } from 'react'; import { normalizeModelId } from '@/lib/model-utils'; import { buildModelProvidersIndex, - canonicalizeModelAllowList, - canonicalizeProviderAllowList, + canonicalizeDenyList, computeAllowedModelIds, computeAllProviderSlugsWithEndpoints, computeEnabledProviderSlugs, sortUniqueStrings, stringListsEqual, - toggleAllowFutureModelsForProvider, toggleModelAllowed, toggleProviderEnabled, type OpenRouterModelSlugSnapshot, @@ -20,10 +18,10 @@ export type ProviderPolicyFilter = 'all' | 'yes' | 'no'; export type ProvidersAndModelsAllowListsReadyState = { status: 'ready'; - draftModelAllowList: string[]; - draftProviderAllowList: string[]; - initialModelAllowList: string[]; - initialProviderAllowList: string[]; + draftModelDenyList: string[]; + draftProviderDenyList: string[]; + initialModelDenyList: string[]; + initialProviderDenyList: string[]; modelSearch: string; modelSelectedOnly: boolean; infoModelId: string | null; @@ -53,27 +51,18 @@ export type ProvidersAndModelsAllowListsState = export type ProvidersAndModelsAllowListsAction = | { type: 'INIT_FROM_SERVER'; - modelAllowList: ReadonlyArray; - providerAllowList: ReadonlyArray; + modelDenyList: ReadonlyArray; + providerDenyList: ReadonlyArray; } | { type: 'TOGGLE_PROVIDER'; providerSlug: string; nextEnabled: boolean; - allProviderSlugsWithEndpoints: ReadonlyArray; } | { type: 'TOGGLE_MODEL'; modelId: string; nextAllowed: boolean; - allModelIds: ReadonlyArray; - providerSlugsForModelId: ReadonlyArray | undefined; - } - | { - type: 'TOGGLE_PROVIDER_WILDCARD'; - providerSlug: string; - nextAllowed: boolean; - allProviderSlugsWithEndpoints: ReadonlyArray; } | { type: 'RESET_TO_INITIAL'; @@ -139,14 +128,14 @@ export function providersAndModelsAllowListsReducer( ): ProvidersAndModelsAllowListsState { switch (action.type) { case 'INIT_FROM_SERVER': { - const nextModelAllowList = canonicalizeModelAllowList(action.modelAllowList); - const nextProviderAllowList = canonicalizeProviderAllowList(action.providerAllowList); + const nextModelDenyList = canonicalizeDenyList(action.modelDenyList); + const nextProviderDenyList = canonicalizeDenyList(action.providerDenyList); return { status: 'ready', - draftModelAllowList: nextModelAllowList, - draftProviderAllowList: nextProviderAllowList, - initialModelAllowList: nextModelAllowList, - initialProviderAllowList: nextProviderAllowList, + draftModelDenyList: nextModelDenyList, + draftProviderDenyList: nextProviderDenyList, + initialModelDenyList: nextModelDenyList, + initialProviderDenyList: nextProviderDenyList, modelSearch: state.modelSearch, modelSelectedOnly: state.modelSelectedOnly, infoModelId: state.infoModelId, @@ -161,53 +150,27 @@ export function providersAndModelsAllowListsReducer( case 'TOGGLE_PROVIDER': { if (state.status !== 'ready') return state; - const { nextModelAllowList, nextProviderAllowList } = toggleProviderEnabled({ + const nextProviderDenyList = toggleProviderEnabled({ providerSlug: action.providerSlug, nextEnabled: action.nextEnabled, - draftProviderAllowList: state.draftProviderAllowList, - draftModelAllowList: state.draftModelAllowList, - allProviderSlugsWithEndpoints: action.allProviderSlugsWithEndpoints, - hadAllProvidersInitially: state.initialProviderAllowList.length === 0, + draftProviderDenyList: state.draftProviderDenyList, }); return { ...state, - draftModelAllowList: nextModelAllowList, - draftProviderAllowList: nextProviderAllowList, + draftProviderDenyList: nextProviderDenyList, }; } case 'TOGGLE_MODEL': { if (state.status !== 'ready') return state; - const nextModelAllowList = toggleModelAllowed({ + const nextModelDenyList = toggleModelAllowed({ modelId: action.modelId, nextAllowed: action.nextAllowed, - draftModelAllowList: state.draftModelAllowList, - allModelIds: action.allModelIds, - providerSlugsForModelId: action.providerSlugsForModelId, - hadAllModelsInitially: state.initialModelAllowList.length === 0, - }); - return { - ...state, - draftModelAllowList: nextModelAllowList, - }; - } - - case 'TOGGLE_PROVIDER_WILDCARD': { - if (state.status !== 'ready') return state; - - const { nextModelAllowList, nextProviderAllowList } = toggleAllowFutureModelsForProvider({ - providerSlug: action.providerSlug, - nextAllowed: action.nextAllowed, - draftModelAllowList: state.draftModelAllowList, - draftProviderAllowList: state.draftProviderAllowList, - allProviderSlugsWithEndpoints: action.allProviderSlugsWithEndpoints, - hadAllProvidersInitially: state.initialProviderAllowList.length === 0, + draftModelDenyList: state.draftModelDenyList, }); - return { ...state, - draftModelAllowList: nextModelAllowList, - draftProviderAllowList: nextProviderAllowList, + draftModelDenyList: nextModelDenyList, }; } @@ -215,8 +178,8 @@ export function providersAndModelsAllowListsReducer( if (state.status !== 'ready') return state; return { ...state, - draftModelAllowList: state.initialModelAllowList, - draftProviderAllowList: state.initialProviderAllowList, + draftModelDenyList: state.initialModelDenyList, + draftProviderDenyList: state.initialProviderDenyList, }; } @@ -224,8 +187,8 @@ export function providersAndModelsAllowListsReducer( if (state.status !== 'ready') return state; return { ...state, - initialModelAllowList: state.draftModelAllowList, - initialProviderAllowList: state.draftProviderAllowList, + initialModelDenyList: state.draftModelDenyList, + initialProviderDenyList: state.draftProviderDenyList, }; } @@ -268,12 +231,11 @@ export function useProvidersAndModelsAllowListsState(params: { selectors: ProvidersAndModelsAllowListsSelectors; actions: { initFromServer: (params: { - modelAllowList: ReadonlyArray; - providerAllowList: ReadonlyArray; + modelDenyList: ReadonlyArray; + providerDenyList: ReadonlyArray; }) => void; toggleProvider: (params: { providerSlug: string; nextEnabled: boolean }) => void; toggleModel: (params: { modelId: string; nextAllowed: boolean }) => void; - toggleProviderWildcard: (params: { providerSlug: string; nextAllowed: boolean }) => void; resetToInitial: () => void; markSaved: () => void; setModelSearch: (value: string) => void; @@ -295,10 +257,10 @@ export function useProvidersAndModelsAllowListsState(params: { createProvidersAndModelsAllowListsInitialState ); - const draftProviderAllowList = state.status === 'ready' ? state.draftProviderAllowList : null; - const draftModelAllowList = state.status === 'ready' ? state.draftModelAllowList : null; - const initialProviderAllowList = state.status === 'ready' ? state.initialProviderAllowList : null; - const initialModelAllowList = state.status === 'ready' ? state.initialModelAllowList : null; + const draftProviderDenyList = state.status === 'ready' ? state.draftProviderDenyList : null; + const draftModelDenyList = state.status === 'ready' ? state.draftModelDenyList : null; + const initialProviderDenyList = state.status === 'ready' ? state.initialProviderDenyList : null; + const initialModelDenyList = state.status === 'ready' ? state.initialModelDenyList : null; const allProviderSlugsWithEndpoints = useMemo(() => { return computeAllProviderSlugsWithEndpoints(openRouterProviders); @@ -313,85 +275,56 @@ export function useProvidersAndModelsAllowListsState(params: { }, [openRouterProviders]); const enabledProviderSlugs = useMemo(() => { - if (!draftProviderAllowList) return new Set(); - return computeEnabledProviderSlugs(draftProviderAllowList, allProviderSlugsWithEndpoints); - }, [allProviderSlugsWithEndpoints, draftProviderAllowList]); + if (!draftProviderDenyList) return new Set(); + return computeEnabledProviderSlugs(draftProviderDenyList, allProviderSlugsWithEndpoints); + }, [allProviderSlugsWithEndpoints, draftProviderDenyList]); const allowedModelIds = useMemo(() => { - if (!draftModelAllowList) return new Set(); - return computeAllowedModelIds(draftModelAllowList, openRouterModels, openRouterProviders); - }, [draftModelAllowList, openRouterModels, openRouterProviders]); + if (!draftModelDenyList) return new Set(); + return computeAllowedModelIds(draftModelDenyList, openRouterModels); + }, [draftModelDenyList, openRouterModels]); const hasUnsavedChanges = useMemo(() => { if ( - !draftModelAllowList || - !draftProviderAllowList || - !initialModelAllowList || - !initialProviderAllowList + !draftModelDenyList || + !draftProviderDenyList || + !initialModelDenyList || + !initialProviderDenyList ) { return false; } return ( - !stringListsEqual(draftModelAllowList, initialModelAllowList) || - !stringListsEqual(draftProviderAllowList, initialProviderAllowList) + !stringListsEqual(draftModelDenyList, initialModelDenyList) || + !stringListsEqual(draftProviderDenyList, initialProviderDenyList) ); - }, [ - draftModelAllowList, - draftProviderAllowList, - initialModelAllowList, - initialProviderAllowList, - ]); + }, [draftModelDenyList, draftProviderDenyList, initialModelDenyList, initialProviderDenyList]); const initFromServer = useCallback( - (init: { modelAllowList: ReadonlyArray; providerAllowList: ReadonlyArray }) => { + (init: { modelDenyList: ReadonlyArray; providerDenyList: ReadonlyArray }) => { dispatch({ type: 'INIT_FROM_SERVER', - modelAllowList: init.modelAllowList, - providerAllowList: init.providerAllowList, + modelDenyList: init.modelDenyList, + providerDenyList: init.providerDenyList, }); }, [] ); - const toggleProvider = useCallback( - (input: { providerSlug: string; nextEnabled: boolean }) => { - dispatch({ - type: 'TOGGLE_PROVIDER', - providerSlug: input.providerSlug, - nextEnabled: input.nextEnabled, - allProviderSlugsWithEndpoints, - }); - }, - [allProviderSlugsWithEndpoints] - ); - - const toggleModel = useCallback( - (input: { modelId: string; nextAllowed: boolean }) => { - const providerSlugsForModelId = modelProvidersIndex.get(input.modelId); - dispatch({ - type: 'TOGGLE_MODEL', - modelId: input.modelId, - nextAllowed: input.nextAllowed, - allModelIds, - providerSlugsForModelId: providerSlugsForModelId - ? sortUniqueStrings([...providerSlugsForModelId]) - : undefined, - }); - }, - [allModelIds, modelProvidersIndex] - ); + const toggleProvider = useCallback((input: { providerSlug: string; nextEnabled: boolean }) => { + dispatch({ + type: 'TOGGLE_PROVIDER', + providerSlug: input.providerSlug, + nextEnabled: input.nextEnabled, + }); + }, []); - const toggleProviderWildcard = useCallback( - (input: { providerSlug: string; nextAllowed: boolean }) => { - dispatch({ - type: 'TOGGLE_PROVIDER_WILDCARD', - providerSlug: input.providerSlug, - nextAllowed: input.nextAllowed, - allProviderSlugsWithEndpoints, - }); - }, - [allProviderSlugsWithEndpoints] - ); + const toggleModel = useCallback((input: { modelId: string; nextAllowed: boolean }) => { + dispatch({ + type: 'TOGGLE_MODEL', + modelId: input.modelId, + nextAllowed: input.nextAllowed, + }); + }, []); const selectors: ProvidersAndModelsAllowListsSelectors = useMemo( () => ({ @@ -417,7 +350,6 @@ export function useProvidersAndModelsAllowListsState(params: { initFromServer, toggleProvider, toggleModel, - toggleProviderWildcard, resetToInitial: () => dispatch({ type: 'RESET_TO_INITIAL' }), markSaved: () => dispatch({ type: 'MARK_SAVED' }), setModelSearch: (value: string) => dispatch({ type: 'SET_MODEL_SEARCH', value }), @@ -436,7 +368,7 @@ export function useProvidersAndModelsAllowListsState(params: { setInfoProviderSlug: (value: string | null) => dispatch({ type: 'SET_INFO_PROVIDER_SLUG', value }), }), - [initFromServer, toggleModel, toggleProvider, toggleProviderWildcard] + [initFromServer, toggleModel, toggleProvider] ); return { diff --git a/src/lib/llm-proxy-helpers.test.ts b/src/lib/llm-proxy-helpers.test.ts index e826ce2156..5763da52e0 100644 --- a/src/lib/llm-proxy-helpers.test.ts +++ b/src/lib/llm-proxy-helpers.test.ts @@ -3,13 +3,12 @@ import { checkOrganizationModelRestrictions, estimateChatTokens } from './llm-pr import type { OpenRouterChatCompletionRequest } from './providers/openrouter/types'; describe('checkOrganizationModelRestrictions', () => { - describe('enterprise plan - model allow list restrictions', () => { - it('should allow model when wildcard matches on enterprise plan', () => { + describe('enterprise plan - model deny list restrictions', () => { + it('should allow model when it is not in the deny list on enterprise plan', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: ['anthropic'], - model_allow_list: ['anthropic/*'], + model_deny_list: ['openai/gpt-4'], }, organizationPlan: 'enterprise', }); @@ -17,92 +16,62 @@ describe('checkOrganizationModelRestrictions', () => { expect(result.error).toBeNull(); }); - it('should allow model when exact match exists on enterprise plan', () => { + it('should block model when it is in the deny list on enterprise plan', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: ['anthropic'], - model_allow_list: ['anthropic/claude-3-opus'], + model_deny_list: ['anthropic/claude-3-opus'], }, organizationPlan: 'enterprise', }); - expect(result.error).toBeNull(); + expect(result.error).not.toBeNull(); + expect(result.error?.status).toBe(404); }); - it('should block model when no match and no wildcard on enterprise plan', () => { + it('should allow any model when deny list is empty on enterprise plan', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: ['anthropic'], - model_allow_list: ['anthropic/claude-3-sonnet'], + model_deny_list: [], }, organizationPlan: 'enterprise', }); - expect(result.error).not.toBeNull(); - expect(result.error?.status).toBe(404); - }); - - it('should allow any model from provider with wildcard on enterprise plan', () => { - const settings = { - provider_allow_list: ['openai'], - model_allow_list: ['openai/*'], - }; - - const gpt4Result = checkOrganizationModelRestrictions({ - modelId: 'openai/gpt-4', - settings, - organizationPlan: 'enterprise', - }); - - const gpt35Result = checkOrganizationModelRestrictions({ - modelId: 'openai/gpt-3.5-turbo', - settings, - organizationPlan: 'enterprise', - }); - - expect(gpt4Result.error).toBeNull(); - expect(gpt35Result.error).toBeNull(); + expect(result.error).toBeNull(); }); - it('should allow when model allow list is empty on enterprise plan', () => { + it('should allow any model when deny list is undefined on enterprise plan', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', - settings: { - provider_allow_list: ['anthropic'], - model_allow_list: [], - }, + settings: {}, organizationPlan: 'enterprise', }); expect(result.error).toBeNull(); }); - it('should handle mixed wildcards and specific models on enterprise plan', () => { + it('should block multiple denied models on enterprise plan', () => { const settings = { - provider_allow_list: ['anthropic', 'openai'], - model_allow_list: ['anthropic/*', 'openai/gpt-4'], + model_deny_list: ['anthropic/claude-3-opus', 'openai/gpt-3.5-turbo'], }; - // Anthropic - any model allowed via wildcard expect( checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings, organizationPlan: 'enterprise', }).error - ).toBeNull(); + ).not.toBeNull(); expect( checkOrganizationModelRestrictions({ - modelId: 'anthropic/claude-3-sonnet', + modelId: 'openai/gpt-3.5-turbo', settings, organizationPlan: 'enterprise', }).error - ).toBeNull(); + ).not.toBeNull(); - // OpenAI - only gpt-4 allowed expect( checkOrganizationModelRestrictions({ modelId: 'openai/gpt-4', @@ -110,52 +79,21 @@ describe('checkOrganizationModelRestrictions', () => { organizationPlan: 'enterprise', }).error ).toBeNull(); - - expect( - checkOrganizationModelRestrictions({ - modelId: 'openai/gpt-3.5-turbo', - settings, - organizationPlan: 'enterprise', - }).error - ).not.toBeNull(); }); }); - describe('teams plan - model allow list should NOT apply', () => { - it('should allow any model on teams plan even with model_allow_list set', () => { + describe('teams plan - model deny list should NOT apply', () => { + it('should allow any model on teams plan even with model_deny_list set', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - model_allow_list: ['openai/gpt-4'], // Only GPT-4 in allow list + model_deny_list: ['anthropic/claude-3-opus'], }, organizationPlan: 'teams', }); - // Teams plan should ignore model_allow_list expect(result.error).toBeNull(); }); - - it('should allow blocked model on teams plan that would be blocked on enterprise', () => { - const settings = { - model_allow_list: ['anthropic/claude-3-sonnet'], - }; - - // On enterprise, this would be blocked - const enterpriseResult = checkOrganizationModelRestrictions({ - modelId: 'anthropic/claude-3-opus', - settings, - organizationPlan: 'enterprise', - }); - expect(enterpriseResult.error).not.toBeNull(); - - // On teams, it should be allowed - const teamsResult = checkOrganizationModelRestrictions({ - modelId: 'anthropic/claude-3-opus', - settings, - organizationPlan: 'teams', - }); - expect(teamsResult.error).toBeNull(); - }); }); describe('no organization plan (individual users)', () => { @@ -163,7 +101,7 @@ describe('checkOrganizationModelRestrictions', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - model_allow_list: ['openai/gpt-4'], + model_deny_list: ['anthropic/claude-3-opus'], }, // No organizationPlan - individual user }); @@ -172,40 +110,40 @@ describe('checkOrganizationModelRestrictions', () => { }); }); - describe('provider allow list - applies to enterprise plans', () => { - it('should return provider config without fields when only provider_allow_list is set for teams', () => { + describe('provider deny list - applies to enterprise plans', () => { + it('should return provider config with ignored providers for enterprise plan', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: ['anthropic', 'openai'], + provider_deny_list: ['openai'], }, - organizationPlan: 'teams', + organizationPlan: 'enterprise', }); expect(result.error).toBeNull(); - expect(result.providerConfig).toBeUndefined(); + expect(result.providerConfig).toEqual({ ignore: ['openai'] }); }); - it('should return provider config on enterprise plan too', () => { + it('should not return providerConfig for teams plan with provider_deny_list', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: ['anthropic'], + provider_deny_list: ['openai'], }, - organizationPlan: 'enterprise', + organizationPlan: 'teams', }); expect(result.error).toBeNull(); - expect(result.providerConfig).toEqual({ only: ['anthropic'] }); + expect(result.providerConfig).toBeUndefined(); }); - it('should not return providerConfig when provider_allow_list is empty', () => { + it('should not return providerConfig when provider_deny_list is empty', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: [], + provider_deny_list: [], }, - organizationPlan: 'teams', + organizationPlan: 'enterprise', }); expect(result.error).toBeNull(); @@ -240,20 +178,18 @@ describe('checkOrganizationModelRestrictions', () => { expect(result.providerConfig).toEqual({ data_collection: 'deny' }); }); - it('should combine provider_allow_list and data_collection in provider config', () => { + it('should combine provider_deny_list and data_collection in provider config', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', settings: { - provider_allow_list: ['anthropic'], + provider_deny_list: ['openai'], data_collection: 'deny', }, - organizationPlan: 'teams', + organizationPlan: 'enterprise', }); expect(result.error).toBeNull(); - expect(result.providerConfig).toEqual({ - data_collection: 'deny', - }); + expect(result.providerConfig).toEqual({ ignore: ['openai'], data_collection: 'deny' }); }); }); diff --git a/src/lib/llm-proxy-helpers.ts b/src/lib/llm-proxy-helpers.ts index d9650e60f1..ea6ea8c4ae 100644 --- a/src/lib/llm-proxy-helpers.ts +++ b/src/lib/llm-proxy-helpers.ts @@ -293,24 +293,12 @@ export function checkOrganizationModelRestrictions(params: { const normalizedModelId = normalizeModelId(params.modelId); - // Model allow list restrictions only apply to Enterprise plans + // Model deny list restrictions only apply to Enterprise plans // Teams plans should allow all models by default if (params.organizationPlan === 'enterprise') { - const modelAllowList = params.settings.model_allow_list || []; - - // If there are model restrictions, check them - if (modelAllowList.length > 0) { - // Check for exact model match - const isExactMatch = modelAllowList.includes(normalizedModelId); - - // Check for wildcard match (e.g., "anthropic/*" matches "anthropic/claude-3-opus") - const providerSlug = normalizedModelId.split('/')[0]; - const wildcardEntry = `${providerSlug}/*`; - const isWildcardMatch = modelAllowList.includes(wildcardEntry); - - if (!isExactMatch && !isWildcardMatch) { - return { error: modelNotAllowedResponse() }; - } + const modelDenyList = params.settings.model_deny_list; + if (modelDenyList && modelDenyList.includes(normalizedModelId)) { + return { error: modelNotAllowedResponse() }; } } diff --git a/src/lib/model-allow.server.test.ts b/src/lib/model-allow.server.test.ts deleted file mode 100644 index c957f69a4e..0000000000 --- a/src/lib/model-allow.server.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from '@jest/globals'; -import { createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server'; -import { createModelsByProviderIndexLoader } from '@/lib/providers/openrouter/models-by-provider-index.server'; -import type { - NormalizedOpenRouterResponse, - OpenRouterModel, -} from '@/lib/providers/openrouter/openrouter-types'; - -function makeOpenRouterModel(slug: string): OpenRouterModel { - return { - slug, - hf_slug: null, - updated_at: '2026-01-01T00:00:00.000Z', - created_at: '2026-01-01T00:00:00.000Z', - hf_updated_at: null, - name: slug, - short_name: slug, - author: 'test', - description: '', - model_version_group_id: null, - context_length: 1, - input_modalities: [], - output_modalities: [], - has_text_output: true, - group: 'test', - instruct_type: null, - default_system: null, - default_stops: [], - hidden: false, - router: null, - warning_message: null, - permaslug: slug, - reasoning_config: null, - features: null, - default_parameters: null, - endpoint: null, - }; -} - -describe('createProviderAwareModelAllowPredicate', () => { - test('provider-membership wildcard allows model even when model namespace differs', async () => { - const snapshot = { - providers: [ - { - name: 'Cerebras', - displayName: 'Cerebras', - slug: 'cerebras', - dataPolicy: { - training: true, - retainsPrompts: true, - canPublish: false, - }, - models: [makeOpenRouterModel('z-ai/glm4.6')], - }, - ], - total_providers: 1, - total_models: 1, - generated_at: '2026-01-01T00:00:00.000Z', - } satisfies NormalizedOpenRouterResponse; - - const loader = createModelsByProviderIndexLoader({ - fetchSnapshot: async () => snapshot, - ttlMs: 60_000, - nowMs: () => 0, - }); - - const isAllowed = createProviderAwareModelAllowPredicate(['cerebras/*'], { - getProviderSlugsForModel: loader.getProviderSlugsForModel, - }); - - await expect(isAllowed('z-ai/glm4.6')).resolves.toBe(true); - await expect(isAllowed('openai/gpt-5.2')).resolves.toBe(false); - }); -}); diff --git a/src/lib/model-allow.server.ts b/src/lib/model-allow.server.ts index 6a55de8e69..8a682ea6fa 100644 --- a/src/lib/model-allow.server.ts +++ b/src/lib/model-allow.server.ts @@ -1,54 +1,9 @@ import 'server-only'; import { normalizeModelId } from '@/lib/model-utils'; -import { - fetchLatestModelsByProviderSnapshotFromDb, - getProviderSlugsForModel, -} from '@/lib/providers/openrouter/models-by-provider-index.server'; -import { - isAllowedByExactOrNamespaceWildcard, - isAllowedByProviderMembershipWildcard, - prepareModelAllowList, -} from '@/lib/model-allow.shared'; - -export type GetProviderSlugsForModel = (modelId: string) => Promise>; - -type ProviderAwareAllowPredicateOptions = { - getProviderSlugsForModel?: GetProviderSlugsForModel; -}; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; -/** @deprecated Use `createAllowPredicateFromDenyList` instead */ -export function createProviderAwareModelAllowPredicate( - allowList: string[], - options?: ProviderAwareAllowPredicateOptions -): ProviderAwareAllowPredicate { - if (allowList.length === 0) { - return async () => true; - } - - const { allowListSet, wildcardProviderSlugs } = prepareModelAllowList(allowList); - - const getProvidersForModel = options?.getProviderSlugsForModel ?? getProviderSlugsForModel; - - return async (modelId: string): Promise => { - const normalizedModelId = normalizeModelId(modelId); - - if (isAllowedByExactOrNamespaceWildcard(normalizedModelId, allowListSet)) { - return true; - } - - // 3) Provider-membership wildcard match - if (wildcardProviderSlugs.size === 0) { - return false; - } - - const providersForModel = await getProvidersForModel(normalizedModelId); - return isAllowedByProviderMembershipWildcard(providersForModel, wildcardProviderSlugs); - }; -} - export function createAllowPredicateFromDenyList( denyList: string[] | undefined ): ProviderAwareAllowPredicate { @@ -58,36 +13,3 @@ export function createAllowPredicateFromDenyList( return Promise.resolve(!denyListSet.has(normalizedModelId)); }; } - -export async function createDenyLists( - model_allow_list: string[] | undefined, - provider_allow_list: string[] | undefined -) { - if (!model_allow_list && !provider_allow_list) { - return undefined; - } - const data = await fetchLatestModelsByProviderSnapshotFromDb(); - if (!data) { - return undefined; - } - const isAllowed = model_allow_list - ? createProviderAwareModelAllowPredicate(model_allow_list) - : undefined; - const model_deny_list = new Set(); - const provider_deny_list = new Set(); - for (const provider of data.providers) { - if ( - provider_allow_list && - provider_allow_list.length > 0 && - !provider_allow_list.includes(provider.slug) - ) { - provider_deny_list.add(provider.slug); - } - for (const model of provider.models) { - if (isAllowed && !(await isAllowed(model.slug))) { - model_deny_list.add(model.slug); - } - } - } - return { model_deny_list: [...model_deny_list], provider_deny_list: [...provider_deny_list] }; -} diff --git a/src/lib/model-allow.shared.ts b/src/lib/model-allow.shared.ts deleted file mode 100644 index ac4857a44d..0000000000 --- a/src/lib/model-allow.shared.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Shared allow-list logic that can be used on both client and server. - * - * IMPORTANT: Keep this file free of server-only dependencies. - */ - -export type PreparedModelAllowList = { - allowListSet: ReadonlySet; - /** - * For each `provider/*` entry, contains the `provider` part. - * - * Note: A namespace wildcard like `openai/*` is also a provider wildcard in practice. - */ - wildcardProviderSlugs: ReadonlySet; -}; - -export function prepareModelAllowList(allowList: string[]): PreparedModelAllowList { - const allowListSet = new Set(allowList); - const wildcardProviderSlugs = new Set( - allowList.filter(entry => entry.endsWith('/*')).map(entry => entry.slice(0, -2)) - ); - - return { - allowListSet, - wildcardProviderSlugs, - }; -} - -export function isAllowedByExactOrNamespaceWildcard( - normalizedModelId: string, - allowListSet: ReadonlySet -): boolean { - // 1) Exact match - if (allowListSet.has(normalizedModelId)) { - return true; - } - - // 2) Namespace wildcard match (backwards compatible) - const namespace = normalizedModelId.split('/')[0]; - const namespaceWildcardEntry = `${namespace}/*`; - return allowListSet.has(namespaceWildcardEntry); -} - -export function isAllowedByProviderMembershipWildcard( - providersForModel: ReadonlySet, - wildcardProviderSlugs: ReadonlySet -): boolean { - for (const providerSlug of wildcardProviderSlugs) { - if (providersForModel.has(providerSlug)) { - return true; - } - } - - return false; -} diff --git a/src/lib/organizations/organization-usage.test.ts b/src/lib/organizations/organization-usage.test.ts index 2197ea11ff..fe4a34e03b 100644 --- a/src/lib/organizations/organization-usage.test.ts +++ b/src/lib/organizations/organization-usage.test.ts @@ -39,13 +39,13 @@ describe('Organization Usage Functions', () => { test('should return balance for organization member', async () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 50000, { - model_allow_list: ['fizz', 'buzz'], + model_deny_list: ['fizz', 'buzz'], }); const result = await getBalanceForOrganizationUser(organization.id, user.id); expect(result.balance).toBe(0.05); // 50000 microdollars = 0.05 USD - expect(result.settings?.model_allow_list).toEqual(['fizz', 'buzz']); // 50000 microdollars = 0.05 USD + expect(result.settings?.model_deny_list).toEqual(['fizz', 'buzz']); // 50000 microdollars = 0.05 USD }); test('should return balance for regular member', async () => { diff --git a/src/routers/organizations/organization-settings-router.test.ts b/src/routers/organizations/organization-settings-router.test.ts index 35db95888f..c9da7cf8b9 100644 --- a/src/routers/organizations/organization-settings-router.test.ts +++ b/src/routers/organizations/organization-settings-router.test.ts @@ -26,7 +26,7 @@ let owner: User; let member: User; let testOrganization: Organization; let orgWithSettings: Organization; -let orgWithModelAllowList: Organization; +let orgWithModelDenyList: Organization; describe('organizations settings trpc router', () => { beforeAll(async () => { @@ -51,62 +51,58 @@ describe('organizations settings trpc router', () => { 'Org With Settings', owner.id, 0, - { model_allow_list: ['gpt-4', 'gpt-3.5-turbo'], provider_allow_list: ['openai'] }, + { model_deny_list: ['gpt-4', 'gpt-3.5-turbo'], provider_deny_list: ['openai'] }, false ); - // Create organization with model allow list for validation tests and require_seats = false - orgWithModelAllowList = await createTestOrganization( - 'Model Allow List', + // Create organization with model deny list for validation tests and require_seats = false + orgWithModelDenyList = await createTestOrganization( + 'Model Deny List', owner.id, 0, - { model_allow_list: ['gpt-3.5-turbo', 'claude-3'], provider_allow_list: ['openai'] }, + { model_deny_list: ['gpt-3.5-turbo', 'claude-3'] }, false ); // Add member to all organizations await addUserToOrganization(testOrganization.id, member.id, 'member'); await addUserToOrganization(orgWithSettings.id, member.id, 'member'); - await addUserToOrganization(orgWithModelAllowList.id, member.id, 'member'); + await addUserToOrganization(orgWithModelDenyList.id, member.id, 'member'); }); describe('updateAllowLists procedure', () => { - it('should update organization allow lists for organization owner', async () => { + it('should update organization deny lists for organization owner', async () => { const caller = await createCallerForUser(owner.id); const result = await caller.organizations.settings.updateAllowLists({ organizationId: testOrganization.id, - model_allow_list: ['gpt-4', 'gpt-3.5-turbo', 'claude-3'], - provider_allow_list: ['openai', 'anthropic'], + model_deny_list: ['gpt-4', 'gpt-3.5-turbo', 'claude-3'], + provider_deny_list: ['openai', 'anthropic'], }); // Verify the result returns the updated settings - expect(result.settings.model_allow_list).toEqual(['gpt-4', 'gpt-3.5-turbo', 'claude-3']); - expect(result.settings.provider_allow_list).toEqual(['openai', 'anthropic']); + expect(result.settings.model_deny_list).toEqual(['gpt-4', 'gpt-3.5-turbo', 'claude-3']); + expect(result.settings.provider_deny_list).toEqual(['openai', 'anthropic']); // Verify the settings were actually persisted to the database const updatedOrg = await getOrganizationById(testOrganization.id); - expect(updatedOrg?.settings?.model_allow_list).toEqual([ - 'gpt-4', - 'gpt-3.5-turbo', - 'claude-3', - ]); - expect(updatedOrg?.settings?.provider_allow_list).toEqual(['openai', 'anthropic']); + expect(updatedOrg?.settings?.model_deny_list).toEqual(['gpt-4', 'gpt-3.5-turbo', 'claude-3']); + expect(updatedOrg?.settings?.provider_deny_list).toEqual(['openai', 'anthropic']); }); - it('should clear default_model if it is not in the new model_allow_list', async () => { + it('should clear default_model if it is in the new model_deny_list', async () => { const caller = await createCallerForUser(owner.id); - // First set a default model + // First set a default model that's NOT denied await updateOrganizationSettings(orgWithSettings.id, { - default_model: 'gpt-4', - model_allow_list: ['gpt-4', 'gpt-3.5-turbo'], + default_model: 'openai/gpt-4o', + model_deny_list: ['gpt-4', 'gpt-3.5-turbo'], }); - // Now update the allow list without gpt-4 + // Now deny the default model const result = await caller.organizations.settings.updateAllowLists({ organizationId: orgWithSettings.id, - model_allow_list: ['gpt-3.5-turbo', 'claude-3'], // gpt-4 not in list + model_deny_list: ['openai/gpt-4o', 'gpt-3.5-turbo'], }); // default_model should be cleared @@ -117,29 +113,28 @@ describe('organizations settings trpc router', () => { expect(updatedOrg?.settings?.default_model).toBeUndefined(); }); - it('should not clear default_model if it is allowed by a provider wildcard in the new model_allow_list', async () => { + it('should not clear default_model if it is not in the new model_deny_list', async () => { const caller = await createCallerForUser(owner.id); - const orgWithWildcardDefaultModel = await createTestOrganization( - 'Org With Wildcard Default Model', + const orgWithDefault = await createTestOrganization( + 'Org With Default Model', owner.id, 0, { default_model: 'openai/gpt-4o', - model_allow_list: ['openai/gpt-4o'], - provider_allow_list: ['openai'], + model_deny_list: [], }, false ); const result = await caller.organizations.settings.updateAllowLists({ - organizationId: orgWithWildcardDefaultModel.id, - model_allow_list: ['openai/*'], + organizationId: orgWithDefault.id, + model_deny_list: ['anthropic/claude-3-opus'], }); expect(result.settings.default_model).toBe('openai/gpt-4o'); - const updatedOrg = await getOrganizationById(orgWithWildcardDefaultModel.id); + const updatedOrg = await getOrganizationById(orgWithDefault.id); expect(updatedOrg?.settings?.default_model).toBe('openai/gpt-4o'); }); @@ -150,7 +145,7 @@ describe('organizations settings trpc router', () => { await expect( caller.organizations.settings.updateAllowLists({ organizationId: nonExistentId, - model_allow_list: ['gpt-4'], + model_deny_list: ['gpt-4'], }) ).rejects.toThrow('You do not have access to this organization'); }); @@ -161,7 +156,7 @@ describe('organizations settings trpc router', () => { await expect( caller.organizations.settings.updateAllowLists({ organizationId: testOrganization.id, - model_allow_list: ['gpt-4'], + model_deny_list: ['gpt-4'], }) ).rejects.toThrow('You do not have the required organizational role to access this feature'); }); @@ -173,7 +168,7 @@ describe('organizations settings trpc router', () => { await expect( caller.organizations.settings.updateAllowLists({ organizationId: 'invalid-uuid', - model_allow_list: ['gpt-4'], + model_deny_list: ['gpt-4'], }) ).rejects.toThrow(); }); @@ -183,47 +178,43 @@ describe('organizations settings trpc router', () => { // First, set initial settings await updateOrganizationSettings(testOrganization.id, { - model_allow_list: ['gpt-4', 'gpt-3.5-turbo'], - provider_allow_list: ['openai'], + model_deny_list: ['gpt-4', 'gpt-3.5-turbo'], + provider_deny_list: ['openai'], }); - // Now update only provider_allow_list - this only updates the specified field + // Now update only provider_deny_list - this only updates the specified field const result = await caller.organizations.settings.updateAllowLists({ organizationId: testOrganization.id, - provider_allow_list: ['openai', 'anthropic'], + provider_deny_list: ['openai', 'anthropic'], }); - // Verify provider_allow_list was updated - expect(result.settings.provider_allow_list).toEqual(['openai', 'anthropic']); + // Verify provider_deny_list was updated + expect(result.settings.provider_deny_list).toEqual(['openai', 'anthropic']); - // Verify from database that model_allow_list is still there + // Verify from database that model_deny_list is still there const updatedOrg = await getOrganizationById(testOrganization.id); - expect(updatedOrg?.settings?.model_allow_list).toEqual(['gpt-4', 'gpt-3.5-turbo']); - expect(updatedOrg?.settings?.provider_allow_list).toEqual(['openai', 'anthropic']); + expect(updatedOrg?.settings?.model_deny_list).toEqual(['gpt-4', 'gpt-3.5-turbo']); + expect(updatedOrg?.settings?.provider_deny_list).toEqual(['openai', 'anthropic']); }); - it('should deduplicate model_allow_list and provider_allow_list entries', async () => { + it('should deduplicate model_deny_list and provider_deny_list entries', async () => { const caller = await createCallerForUser(owner.id); // Send arrays with duplicate entries const result = await caller.organizations.settings.updateAllowLists({ organizationId: testOrganization.id, - model_allow_list: ['gpt-4', 'gpt-4', 'gpt-3.5-turbo', 'gpt-4', 'claude-3'], - provider_allow_list: ['openai', 'openai', 'anthropic', 'openai'], + model_deny_list: ['gpt-4', 'gpt-4', 'gpt-3.5-turbo', 'gpt-4', 'claude-3'], + provider_deny_list: ['openai', 'openai', 'anthropic', 'openai'], }); // Should be deduplicated to unique values only - expect(result.settings.model_allow_list).toEqual(['gpt-4', 'gpt-3.5-turbo', 'claude-3']); - expect(result.settings.provider_allow_list).toEqual(['openai', 'anthropic']); + expect(result.settings.model_deny_list).toEqual(['gpt-4', 'gpt-3.5-turbo', 'claude-3']); + expect(result.settings.provider_deny_list).toEqual(['openai', 'anthropic']); // Verify the deduplicated data was persisted to the database const updatedOrg = await getOrganizationById(testOrganization.id); - expect(updatedOrg?.settings?.model_allow_list).toEqual([ - 'gpt-4', - 'gpt-3.5-turbo', - 'claude-3', - ]); - expect(updatedOrg?.settings?.provider_allow_list).toEqual(['openai', 'anthropic']); + expect(updatedOrg?.settings?.model_deny_list).toEqual(['gpt-4', 'gpt-3.5-turbo', 'claude-3']); + expect(updatedOrg?.settings?.provider_deny_list).toEqual(['openai', 'anthropic']); }); }); @@ -250,7 +241,7 @@ describe('organizations settings trpc router', () => { }; } - it('should include all provider models when model_allow_list contains a provider wildcard (openai/*)', async () => { + it('should exclude denied models for enterprise orgs', async () => { const openRouterModelsResponse = { data: [ makeOpenRouterModel('openai/gpt-4o'), @@ -262,24 +253,27 @@ describe('organizations settings trpc router', () => { const mockedGetEnhancedOpenRouterModels = jest.mocked(getEnhancedOpenRouterModels); mockedGetEnhancedOpenRouterModels.mockResolvedValue(openRouterModelsResponse); - const orgWithWildcardAllowList = await createTestOrganization( - 'Wildcard Model Allow List', + const orgWithDenyList = await createTestOrganization( + 'Model Deny List', owner.id, 0, - { model_allow_list: ['openai/*'] }, + { model_deny_list: ['openai/gpt-4o', 'openai/gpt-4o'] }, false ); - await addUserToOrganization(orgWithWildcardAllowList.id, member.id, 'member'); + await addUserToOrganization(orgWithDenyList.id, member.id, 'member'); const caller = await createCallerForUser(member.id); const result = await caller.organizations.settings.listAvailableModels({ - organizationId: orgWithWildcardAllowList.id, + organizationId: orgWithDenyList.id, }); - expect(result.data.map(model => model.id)).toEqual(['openai/gpt-4o', 'openai/gpt-4o:free']); + expect(result.data.map(model => model.id)).toEqual([ + 'openai/gpt-4o:free', + 'anthropic/claude-3-opus', + ]); }); - it('should return all models for a non-enterprise org even if model_allow_list is set', async () => { + it('should return all models for a non-enterprise org even if model_deny_list is set', async () => { const openRouterModelsResponse = { data: [ makeOpenRouterModel('openai/gpt-4o'), @@ -292,10 +286,10 @@ describe('organizations settings trpc router', () => { // requireSeats: true sets plan to 'teams' const teamsOrg = await createTestOrganization( - 'Teams Org With Allow List', + 'Teams Org With Deny List', owner.id, 0, - { model_allow_list: ['openai/*'] }, + { model_deny_list: ['openai/gpt-4o'] }, true ); await addUserToOrganization(teamsOrg.id, member.id, 'member'); @@ -305,7 +299,7 @@ describe('organizations settings trpc router', () => { organizationId: teamsOrg.id, }); - // Teams orgs should see all models, ignoring the allow list + // Teams orgs should see all models, ignoring the deny list expect(result.data.map(model => model.id)).toEqual([ 'openai/gpt-4o', 'anthropic/claude-3-opus', @@ -314,12 +308,12 @@ describe('organizations settings trpc router', () => { }); describe('updateDefaultModel procedure', () => { - it('should update default model when it is in the allow list', async () => { + it('should update default model when it is not denied', async () => { const caller = await createCallerForUser(owner.id); - // First set up an allow list + // Set a deny list that excludes some models but not gpt-4 await updateOrganizationSettings(orgWithSettings.id, { - model_allow_list: ['gpt-4', 'gpt-3.5-turbo'], + model_deny_list: ['claude-3', 'gpt-3.5-turbo'], }); // Now set the default model @@ -335,46 +329,26 @@ describe('organizations settings trpc router', () => { expect(updatedOrg?.settings?.default_model).toBe('gpt-4'); }); - it('should update default model when it is allowed by a provider wildcard in model_allow_list', async () => { - const caller = await createCallerForUser(owner.id); - - const orgWithWildcardAllowList = await createTestOrganization( - 'Default Model Wildcard Allow List', - owner.id, - 0, - { model_allow_list: ['openai/*'], provider_allow_list: ['openai'] }, - false - ); - - const result = await caller.organizations.settings.updateDefaultModel({ - organizationId: orgWithWildcardAllowList.id, - default_model: 'openai/gpt-4o', - }); - - expect(result.settings.default_model).toBe('openai/gpt-4o'); - - const updatedOrg = await getOrganizationById(orgWithWildcardAllowList.id); - expect(updatedOrg?.settings?.default_model).toBe('openai/gpt-4o'); - }); - - it('should reject default_model not in the allow list', async () => { + it('should reject default_model if it is in the deny list', async () => { const caller = await createCallerForUser(owner.id); - // Org has model_allow_list: ['gpt-3.5-turbo', 'claude-3'] + // orgWithModelDenyList has model_deny_list: ['gpt-3.5-turbo', 'claude-3'] await expect( caller.organizations.settings.updateDefaultModel({ - organizationId: orgWithModelAllowList.id, - default_model: 'gpt-4', // Not in the list + organizationId: orgWithModelDenyList.id, + default_model: 'gpt-3.5-turbo', }) - ).rejects.toThrow("Default model 'gpt-4' is not in the organization's allowed models list"); + ).rejects.toThrow( + "Default model 'gpt-3.5-turbo' is not in the organization's allowed models list" + ); }); - it('should allow any model when allow list is empty', async () => { + it('should allow any model when deny list is empty', async () => { const caller = await createCallerForUser(owner.id); - // Clear the allow list + // Clear the deny list await updateOrganizationSettings(testOrganization.id, { - model_allow_list: [], + model_deny_list: [], }); // Should be able to set any model @@ -551,7 +525,7 @@ describe('organizations settings trpc router', () => { // First set some other settings await updateOrganizationSettings(testOrganization.id, { - model_allow_list: ['gpt-4'], + model_deny_list: ['gpt-4'], data_collection: 'allow', }); @@ -564,7 +538,7 @@ describe('organizations settings trpc router', () => { }); // Other settings should be preserved - expect(result.settings.model_allow_list).toEqual(['gpt-4']); + expect(result.settings.model_deny_list).toEqual(['gpt-4']); expect(result.settings.data_collection).toBe('allow'); expect(result.settings.minimum_balance).toBe(100); expect(result.settings.minimum_balance_alert_email).toEqual(['alert@example.com']); @@ -575,7 +549,7 @@ describe('organizations settings trpc router', () => { // First set some settings including minimum balance await updateOrganizationSettings(testOrganization.id, { - model_allow_list: ['gpt-4'], + model_deny_list: ['gpt-4'], data_collection: 'allow', minimum_balance: 100, minimum_balance_alert_email: ['alert@example.com'], @@ -588,7 +562,7 @@ describe('organizations settings trpc router', () => { }); // Other settings should be preserved, but minimum balance fields removed - expect(result.settings.model_allow_list).toEqual(['gpt-4']); + expect(result.settings.model_deny_list).toEqual(['gpt-4']); expect(result.settings.data_collection).toBe('allow'); expect(result.settings.minimum_balance).toBeUndefined(); expect(result.settings.minimum_balance_alert_email).toBeUndefined(); diff --git a/src/routers/organizations/organization-settings-router.ts b/src/routers/organizations/organization-settings-router.ts index 7dcafc4d10..3a1c9b1788 100644 --- a/src/routers/organizations/organization-settings-router.ts +++ b/src/routers/organizations/organization-settings-router.ts @@ -15,7 +15,7 @@ import * as z from 'zod'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; import { getEnhancedOpenRouterModels } from '@/lib/providers/openrouter'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; -import { createAllowPredicateFromDenyList, createDenyLists } from '@/lib/model-allow.server'; +import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; import { KILO_ORGANIZATION_ID } from '@/lib/organizations/constants'; import { listAvailableCustomLlms } from '@/lib/custom-llm/listAvailableCustomLlms'; @@ -28,51 +28,46 @@ const PRIVILEGED_ORGANIZATION_IDS = [ ] as const; /** - * Creates a human-readable diff message for allow list changes + * Creates a human-readable diff message for deny list changes */ -function createAllowListsDiffMessage( +function createDenyListsDiffMessage( oldSettings: OrganizationSettings | undefined, newSettings: OrganizationSettings ): string { const changes: string[] = []; const old = oldSettings || {}; - // Compare model_allow_list - if (old.model_allow_list !== newSettings.model_allow_list) { - const oldModels = new Set(old.model_allow_list || []); - const newModels = new Set(newSettings.model_allow_list || []); + if (old.model_deny_list !== newSettings.model_deny_list) { + const oldModels = new Set(old.model_deny_list || []); + const newModels = new Set(newSettings.model_deny_list || []); const added = [...newModels].filter(model => !oldModels.has(model)); const removed = [...oldModels].filter(model => !newModels.has(model)); if (added.length > 0) { - changes.push(`Added models: ${added.join(', ')}`); + changes.push(`Added to model deny list: ${added.join(', ')}`); } if (removed.length > 0) { - changes.push(`Removed models: ${removed.join(', ')}`); - } - if (added.length === 0 && removed.length === 0 && oldModels.size !== newModels.size) { - changes.push(`Updated model allow list (${oldModels.size} → ${newModels.size} models)`); + changes.push(`Removed from model deny list: ${removed.join(', ')}`); } } - // Compare provider_allow_list - if (old.provider_allow_list !== newSettings.provider_allow_list) { - const oldProviders = new Set(old.provider_allow_list || []); - const newProviders = new Set(newSettings.provider_allow_list || []); + if (old.provider_deny_list !== newSettings.provider_deny_list) { + const oldProviders = new Set(old.provider_deny_list || []); + const newProviders = new Set(newSettings.provider_deny_list || []); const added = [...newProviders].filter(provider => !oldProviders.has(provider)); const removed = [...oldProviders].filter(provider => !newProviders.has(provider)); if (added.length > 0) { - changes.push(`Added providers: ${added.join(', ')}`); + changes.push(`Added to provider deny list: ${added.join(', ')}`); } if (removed.length > 0) { - changes.push(`Removed providers: ${removed.join(', ')}`); + changes.push(`Removed from provider deny list: ${removed.join(', ')}`); } } - return changes.length > 0 ? changes.join('; ') : 'Updated allow lists'; + return changes.length > 0 ? changes.join('; ') : 'Updated deny lists'; } /** @@ -98,8 +93,8 @@ function createDefaultModelDiffMessage( } const UpdateAllowListsInputSchema = OrganizationIdInputSchema.extend({ - model_allow_list: z.array(z.string()).optional(), - provider_allow_list: z.array(z.string()).optional(), + model_deny_list: z.array(z.string()).optional(), + provider_deny_list: z.array(z.string()).optional(), }); const UpdateDefaultModelInputSchema = OrganizationIdInputSchema.extend({ @@ -190,7 +185,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ .input(UpdateAllowListsInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { - const { organizationId, model_allow_list, provider_allow_list } = input; + const { organizationId, model_deny_list, provider_deny_list } = input; await requireActiveSubscriptionOrTrial(organizationId); @@ -216,20 +211,11 @@ export const organizationsSettingsRouter = createTRPCRouter({ ...currentSettings, }; - if (model_allow_list !== undefined) { - settingsUpdate.model_allow_list = [...new Set(model_allow_list)]; // Deduplicate slugs - } - if (provider_allow_list !== undefined) { - settingsUpdate.provider_allow_list = [...new Set(provider_allow_list)]; // Deduplicate slugs + if (model_deny_list !== undefined) { + settingsUpdate.model_deny_list = [...new Set(model_deny_list)]; // Deduplicate slugs } - - const denyLists = await createDenyLists( - settingsUpdate.model_allow_list, - settingsUpdate.provider_allow_list - ); - if (denyLists) { - settingsUpdate.model_deny_list = denyLists.model_deny_list; - settingsUpdate.provider_deny_list = denyLists.provider_deny_list; + if (provider_deny_list !== undefined) { + settingsUpdate.provider_deny_list = [...new Set(provider_deny_list)]; // Deduplicate slugs } // Check if default_model needs to be cleared @@ -241,7 +227,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ const isAllowed = createAllowPredicateFromDenyList(settingsUpdate.model_deny_list); if (!(await isAllowed(currentSettings.default_model))) { - // Clear default_model if it's no longer in the allow list + // Clear default_model if it's no longer allowed settingsUpdate.default_model = undefined; } } @@ -253,7 +239,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ actor_email: ctx.user.google_user_email, actor_id: ctx.user.id, actor_name: ctx.user.google_user_name, - message: createAllowListsDiffMessage(existingOrg.settings, updatedSettings), + message: createDenyListsDiffMessage(existingOrg.settings, updatedSettings), organization_id: organizationId, }); @@ -286,7 +272,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ }); } - // Validate default_model against existing model_allow_list + // Validate default_model against existing model_deny_list const existingDeniedModels = existingOrg.settings?.model_deny_list; if (existingDeniedModels && existingDeniedModels.length > 0) { const isAllowed = createAllowPredicateFromDenyList(existingDeniedModels); From 4db4d519c1cc81774dca669104d9fd14c395ba7c Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 13:44:24 +0100 Subject: [PATCH 08/14] fix deny list: correct fallback model logic, providerConfig guard, and stale allow-list references --- .../api/organizations/[id]/defaults/route.test.ts | 3 ++- src/app/api/organizations/[id]/defaults/route.ts | 9 ++++++--- src/lib/llm-proxy-helpers.ts | 2 +- src/lib/model-allow.server.ts | 5 +---- src/lib/slack-bot/model-allow-list.ts | 12 +++--------- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/app/api/organizations/[id]/defaults/route.test.ts b/src/app/api/organizations/[id]/defaults/route.test.ts index 9f20a96e88..7d10d6079d 100644 --- a/src/app/api/organizations/[id]/defaults/route.test.ts +++ b/src/app/api/organizations/[id]/defaults/route.test.ts @@ -175,7 +175,8 @@ describe('GET /api/organizations/[id]/defaults', () => { expect(response.status).toBe(409); const body = await response.json(); expect(body).toEqual({ - error: "No valid models are allowed by this organization's allow list.", + error: + "No valid models are available — all models are blocked by this organization's deny list.", }); }); }); diff --git a/src/app/api/organizations/[id]/defaults/route.ts b/src/app/api/organizations/[id]/defaults/route.ts index 9559c1d432..2c4c340fde 100644 --- a/src/app/api/organizations/[id]/defaults/route.ts +++ b/src/app/api/organizations/[id]/defaults/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; import type { NextRequest } from 'next/server'; -import { PRIMARY_DEFAULT_MODEL, getFirstFreeModel, preferredModels } from '@/lib/models'; +import { PRIMARY_DEFAULT_MODEL, getFirstFreeModel } from '@/lib/models'; import { getEnhancedOpenRouterModels } from '@/lib/providers/openrouter'; import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; import { getModelIdToProviderSlugsIndex } from '@/lib/providers/openrouter/models-by-provider-index.server'; @@ -74,7 +74,7 @@ export async function GET( // No restrictions - use PRIMARY_DEFAULT_MODEL directly defaultModel = PRIMARY_DEFAULT_MODEL; } else { - defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL, ...preferredModels]); + defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL]); if (!defaultModel) { defaultModel = await findFirstAllowedModelFromDbSnapshot(); @@ -86,7 +86,10 @@ export async function GET( if (!defaultModel) { return NextResponse.json( - { error: "No valid models are allowed by this organization's allow list." }, + { + error: + "No valid models are available — all models are blocked by this organization's deny list.", + }, { status: 409 } ); } diff --git a/src/lib/llm-proxy-helpers.ts b/src/lib/llm-proxy-helpers.ts index ea6ea8c4ae..026cf91a0a 100644 --- a/src/lib/llm-proxy-helpers.ts +++ b/src/lib/llm-proxy-helpers.ts @@ -307,7 +307,7 @@ export function checkOrganizationModelRestrictions(params: { const providerConfig: OpenRouterProviderConfig = {}; - if (params.organizationPlan === 'enterprise') { + if (params.organizationPlan === 'enterprise' && providerDenyList && providerDenyList.length > 0) { providerConfig.ignore = providerDenyList; } diff --git a/src/lib/model-allow.server.ts b/src/lib/model-allow.server.ts index 8a682ea6fa..9ab184e896 100644 --- a/src/lib/model-allow.server.ts +++ b/src/lib/model-allow.server.ts @@ -1,7 +1,5 @@ import 'server-only'; -import { normalizeModelId } from '@/lib/model-utils'; - export type ProviderAwareAllowPredicate = (modelId: string) => Promise; export function createAllowPredicateFromDenyList( @@ -9,7 +7,6 @@ export function createAllowPredicateFromDenyList( ): ProviderAwareAllowPredicate { const denyListSet = new Set(denyList); return (modelId: string): Promise => { - const normalizedModelId = normalizeModelId(modelId); - return Promise.resolve(!denyListSet.has(normalizedModelId)); + return Promise.resolve(!denyListSet.has(modelId)); }; } diff --git a/src/lib/slack-bot/model-allow-list.ts b/src/lib/slack-bot/model-allow-list.ts index 54210cd4e2..e5b09931c2 100644 --- a/src/lib/slack-bot/model-allow-list.ts +++ b/src/lib/slack-bot/model-allow-list.ts @@ -4,7 +4,7 @@ import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; /** * Get a default model that is allowed for an organization. - * Priority: org default model > preferred models > first non-wildcard in allow list. + * Priority: org default model > global default > preferred models > global default fallback. */ export async function getDefaultAllowedModel( organizationId: string, @@ -41,15 +41,9 @@ export async function getDefaultAllowedModel( } } - // Fall back to the first non-wildcard model in the allow list - const firstNonWildcard = modelDenyList.find(m => !m.endsWith('/*')); - if (firstNonWildcard) { - return firstNonWildcard; - } - - // If only wildcards, fall back to global default (admin misconfiguration) + // All models were blocked; fall back to global default console.warn( - '[SlackBot] Organization has only wildcard entries in model allow list:', + '[SlackBot] No allowed model found; deny list blocks all preferred models:', modelDenyList ); return globalDefault; From 048b3bfed9c302a9a2670d0ca75beaf3e2cb999a Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 13:57:36 +0100 Subject: [PATCH 09/14] Review comments addressed by agent --- .../OrganizationProvidersAndModelsConfigurationCard.tsx | 3 ++- src/lib/model-allow.server.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx index 3153172dc7..c3c4c711b1 100644 --- a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx +++ b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx @@ -60,7 +60,8 @@ export function computeProviderSelectionsForSummaryCard(params: { return null; } - return selections.length > 0 ? selections : null; + // Empty array means restrictions exist but nothing survived — distinct from null ("no restrictions") + return selections.length > 0 ? selections : []; } export function OrganizationProvidersAndModelsConfigurationCard({ diff --git a/src/lib/model-allow.server.ts b/src/lib/model-allow.server.ts index 9ab184e896..22482422ed 100644 --- a/src/lib/model-allow.server.ts +++ b/src/lib/model-allow.server.ts @@ -1,12 +1,13 @@ import 'server-only'; +import { normalizeModelId } from '@/lib/model-utils'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; export function createAllowPredicateFromDenyList( denyList: string[] | undefined ): ProviderAwareAllowPredicate { - const denyListSet = new Set(denyList); + const denyListSet = new Set(denyList?.map(normalizeModelId)); return (modelId: string): Promise => { - return Promise.resolve(!denyListSet.has(modelId)); + return Promise.resolve(!denyListSet.has(normalizeModelId(modelId))); }; } From 0ef632e619cc5bd8bcbac3f2286c73e7e319c846 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Wed, 4 Mar 2026 16:09:27 +0100 Subject: [PATCH 10/14] Address review comments: normalize deny list entries, rename schema, fix tests --- ...rganizationProvidersAndModelsConfigurationCard.test.ts | 8 ++++---- src/lib/llm-proxy-helpers.ts | 5 ++++- .../organizations/organization-settings-router.test.ts | 6 ++---- src/routers/organizations/organization-settings-router.ts | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts index 01e9a581cf..b04a4ff277 100644 --- a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts +++ b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts @@ -102,7 +102,7 @@ describe('computeProviderSelectionsForSummaryCard', () => { ]); }); - test('returns null when all providers are denied', () => { + test('returns empty array when all providers are denied (distinct from null which means no restrictions)', () => { const openRouterProviders = [ { slug: 'openai', @@ -116,7 +116,7 @@ describe('computeProviderSelectionsForSummaryCard', () => { modelDenyList: [], }); - expect(selections).toBeNull(); + expect(selections).toEqual([]); }); test('models without endpoint are excluded', () => { @@ -136,7 +136,7 @@ describe('computeProviderSelectionsForSummaryCard', () => { modelDenyList: ['anthropic/claude-3-opus'], }); - // Both models are excluded (one by deny list, one by no endpoint) - expect(selections).toBeNull(); + // Both models are excluded (one by deny list, one by no endpoint); deny list is non-empty so [] not null + expect(selections).toEqual([]); }); }); diff --git a/src/lib/llm-proxy-helpers.ts b/src/lib/llm-proxy-helpers.ts index 026cf91a0a..9dbb091d89 100644 --- a/src/lib/llm-proxy-helpers.ts +++ b/src/lib/llm-proxy-helpers.ts @@ -297,7 +297,10 @@ export function checkOrganizationModelRestrictions(params: { // Teams plans should allow all models by default if (params.organizationPlan === 'enterprise') { const modelDenyList = params.settings.model_deny_list; - if (modelDenyList && modelDenyList.includes(normalizedModelId)) { + if ( + modelDenyList && + modelDenyList.some(entry => normalizeModelId(entry) === normalizedModelId) + ) { return { error: modelNotAllowedResponse() }; } } diff --git a/src/routers/organizations/organization-settings-router.test.ts b/src/routers/organizations/organization-settings-router.test.ts index c9da7cf8b9..6262622b0a 100644 --- a/src/routers/organizations/organization-settings-router.test.ts +++ b/src/routers/organizations/organization-settings-router.test.ts @@ -267,10 +267,8 @@ describe('organizations settings trpc router', () => { organizationId: orgWithDenyList.id, }); - expect(result.data.map(model => model.id)).toEqual([ - 'openai/gpt-4o:free', - 'anthropic/claude-3-opus', - ]); + // openai/gpt-4o:free normalizes to openai/gpt-4o, which is on the deny list, so it's also blocked + expect(result.data.map(model => model.id)).toEqual(['anthropic/claude-3-opus']); }); it('should return all models for a non-enterprise org even if model_deny_list is set', async () => { diff --git a/src/routers/organizations/organization-settings-router.ts b/src/routers/organizations/organization-settings-router.ts index 3a1c9b1788..50c3410ef4 100644 --- a/src/routers/organizations/organization-settings-router.ts +++ b/src/routers/organizations/organization-settings-router.ts @@ -92,7 +92,7 @@ function createDefaultModelDiffMessage( return 'Updated default model'; } -const UpdateAllowListsInputSchema = OrganizationIdInputSchema.extend({ +const UpdateDenyListsInputSchema = OrganizationIdInputSchema.extend({ model_deny_list: z.array(z.string()).optional(), provider_deny_list: z.array(z.string()).optional(), }); @@ -182,7 +182,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ }), updateAllowLists: organizationOwnerProcedure - .input(UpdateAllowListsInputSchema) + .input(UpdateDenyListsInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { const { organizationId, model_deny_list, provider_deny_list } = input; From db4f4aeb2ff2ad4511121283f575b800ea1243a7 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 6 Mar 2026 14:21:59 +0100 Subject: [PATCH 11/14] Remove unused imports --- src/lib/integrations/discord-service.ts | 1 - src/lib/integrations/slack-service.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/lib/integrations/discord-service.ts b/src/lib/integrations/discord-service.ts index 91c45ad4b6..d21f7392ff 100644 --- a/src/lib/integrations/discord-service.ts +++ b/src/lib/integrations/discord-service.ts @@ -11,7 +11,6 @@ import { APP_URL } from '@/lib/constants'; import { getOrganizationById } from '@/lib/organizations/organizations'; import { getDefaultAllowedModel } from '@/lib/slack-bot/model-allow-list'; import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; -import { minimax_m25_free_model } from '@/lib/providers/minimax'; import { KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model'; // Default model for Discord integrations - mirrors the Slack default diff --git a/src/lib/integrations/slack-service.ts b/src/lib/integrations/slack-service.ts index 77efa7fe01..0b887e450a 100644 --- a/src/lib/integrations/slack-service.ts +++ b/src/lib/integrations/slack-service.ts @@ -13,7 +13,6 @@ import type { OAuthV2Response } from '@slack/oauth'; import { getOrganizationById } from '@/lib/organizations/organizations'; import { getDefaultAllowedModel } from '@/lib/slack-bot/model-allow-list'; import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server'; -import { minimax_m25_free_model } from '@/lib/providers/minimax'; import { KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model'; // Default model for Slack integrations - separate from the global platform default From ed7728224d981b0d164c0881a0ef11c034f8a05e Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 6 Mar 2026 15:01:36 +0100 Subject: [PATCH 12/14] This text doesn't make sense anymore --- src/components/models/CondensedProviderAndModelsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/models/CondensedProviderAndModelsList.tsx b/src/components/models/CondensedProviderAndModelsList.tsx index ee7612a33e..2bb1aa9cf3 100644 --- a/src/components/models/CondensedProviderAndModelsList.tsx +++ b/src/components/models/CondensedProviderAndModelsList.tsx @@ -58,7 +58,7 @@ export function CondensedProviderAndModelsList({ } if (!selections || providersWithSelections.length === 0) { - return
No providers selected
; + return null; } return ( From 9944ee2ec747061aa03c28cff3d4cf21bd922db9 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 6 Mar 2026 15:42:48 +0100 Subject: [PATCH 13/14] Also filter models when all providers are denied --- .../api/organizations/[id]/defaults/route.ts | 7 +++-- src/lib/integrations/discord-service.ts | 5 ++-- src/lib/integrations/slack-service.ts | 5 ++-- src/lib/model-allow.server.ts | 21 +++++++++++--- src/lib/slack-bot/model-allow-list.ts | 5 ++-- .../organization-settings-router.ts | 28 +++++++++++++------ 6 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/app/api/organizations/[id]/defaults/route.ts b/src/app/api/organizations/[id]/defaults/route.ts index 84b82d0c3b..f82effc914 100644 --- a/src/app/api/organizations/[id]/defaults/route.ts +++ b/src/app/api/organizations/[id]/defaults/route.ts @@ -27,9 +27,10 @@ export async function GET( // Get organization's default model setting let defaultModel = organization.settings?.default_model; - const denyList = organization.settings?.model_deny_list; + const modelDenyList = organization.settings?.model_deny_list; + const providerDenyList = organization.settings?.provider_deny_list; - const isAllowed = createAllowPredicateFromDenyList(denyList ?? []); + const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList); const findFirstAllowedModel = async (modelIds: readonly string[]) => { for (const modelId of modelIds) { @@ -71,7 +72,7 @@ export async function GET( // Fallback to global default if no organization default is set or it's not allowed if (!defaultModel) { - if (!denyList?.length) { + if (!modelDenyList?.length && !providerDenyList?.length) { // No restrictions - use PRIMARY_DEFAULT_MODEL directly defaultModel = PRIMARY_DEFAULT_MODEL; } else { diff --git a/src/lib/integrations/discord-service.ts b/src/lib/integrations/discord-service.ts index d21f7392ff..2a34706e28 100644 --- a/src/lib/integrations/discord-service.ts +++ b/src/lib/integrations/discord-service.ts @@ -365,8 +365,9 @@ export async function updateModel( const organization = await getOrganizationById(owner.id); if (organization) { const modelDenyList = organization.settings?.model_deny_list || []; - if (modelDenyList.length > 0) { - const isAllowed = createAllowPredicateFromDenyList(modelDenyList); + const providerDenyList = organization.settings?.provider_deny_list || []; + if (modelDenyList.length > 0 || providerDenyList.length > 0) { + const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList); if (!(await isAllowed(modelSlug))) { return { success: false, error: 'Model is not allowed by organization policy' }; } diff --git a/src/lib/integrations/slack-service.ts b/src/lib/integrations/slack-service.ts index 0b887e450a..b94c55319a 100644 --- a/src/lib/integrations/slack-service.ts +++ b/src/lib/integrations/slack-service.ts @@ -479,8 +479,9 @@ export async function updateModel( const organization = await getOrganizationById(owner.id); if (organization) { const modelDenyList = organization.settings?.model_deny_list || []; - if (modelDenyList.length > 0) { - const isAllowed = createAllowPredicateFromDenyList(modelDenyList); + const providerDenyList = organization.settings?.provider_deny_list || []; + if (modelDenyList.length > 0 || providerDenyList.length > 0) { + const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList); if (!(await isAllowed(modelSlug))) { return { success: false, error: 'Model is not allowed by organization policy' }; } diff --git a/src/lib/model-allow.server.ts b/src/lib/model-allow.server.ts index 22482422ed..6edf05d3e9 100644 --- a/src/lib/model-allow.server.ts +++ b/src/lib/model-allow.server.ts @@ -1,13 +1,26 @@ import 'server-only'; import { normalizeModelId } from '@/lib/model-utils'; +import { getProviderSlugsForModel } from '@/lib/providers/openrouter/models-by-provider-index.server'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; export function createAllowPredicateFromDenyList( - denyList: string[] | undefined + modelDenyList: string[] | undefined, + providerDenyList: string[] | undefined ): ProviderAwareAllowPredicate { - const denyListSet = new Set(denyList?.map(normalizeModelId)); - return (modelId: string): Promise => { - return Promise.resolve(!denyListSet.has(normalizeModelId(modelId))); + const modelDenySet = new Set(modelDenyList?.map(normalizeModelId)); + const providerDenySet = new Set(providerDenyList); + return async (modelId: string): Promise => { + const normalizedModelId = normalizeModelId(modelId); + if (modelDenySet.has(normalizedModelId)) { + return false; + } + if (providerDenySet.size > 0) { + const providerSlugs = await getProviderSlugsForModel(normalizedModelId); + if (providerSlugs.size > 0 && [...providerSlugs].every(slug => providerDenySet.has(slug))) { + return false; + } + } + return true; }; } diff --git a/src/lib/slack-bot/model-allow-list.ts b/src/lib/slack-bot/model-allow-list.ts index e5b09931c2..1fc74edac7 100644 --- a/src/lib/slack-bot/model-allow-list.ts +++ b/src/lib/slack-bot/model-allow-list.ts @@ -16,13 +16,14 @@ export async function getDefaultAllowedModel( } const modelDenyList = organization.settings?.model_deny_list || []; + const providerDenyList = organization.settings?.provider_deny_list || []; // If no restrictions, use global default - if (modelDenyList.length === 0) { + if (modelDenyList.length === 0 && providerDenyList.length === 0) { return globalDefault; } - const isAllowed = createAllowPredicateFromDenyList(modelDenyList); + const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList); // Check if the organization's default model is allowed const orgDefaultModel = organization.settings?.default_model; diff --git a/src/routers/organizations/organization-settings-router.ts b/src/routers/organizations/organization-settings-router.ts index 50c3410ef4..5d89a48352 100644 --- a/src/routers/organizations/organization-settings-router.ts +++ b/src/routers/organizations/organization-settings-router.ts @@ -154,16 +154,18 @@ export const organizationsSettingsRouter = createTRPCRouter({ } let deniedModels: string[] | undefined; + let deniedProviders: string[] | undefined; - if (organization.plan === 'enterprise' && organization?.settings?.model_deny_list) { - deniedModels = organization.settings.model_deny_list; + if (organization.plan === 'enterprise') { + deniedModels = organization.settings?.model_deny_list; + deniedProviders = organization.settings?.provider_deny_list; } const responseData = await getEnhancedOpenRouterModels(); let filteredModels = responseData.data; - if (deniedModels) { - const isAllowed = createAllowPredicateFromDenyList(deniedModels); + if (deniedModels?.length || deniedProviders?.length) { + const isAllowed = createAllowPredicateFromDenyList(deniedModels, deniedProviders); const models: OpenRouterModel[] = []; for (const model of responseData.data) { if (await isAllowed(model.id)) { @@ -224,7 +226,10 @@ export const organizationsSettingsRouter = createTRPCRouter({ currentSettings.default_model && settingsUpdate.model_deny_list.length > 0 ) { - const isAllowed = createAllowPredicateFromDenyList(settingsUpdate.model_deny_list); + const isAllowed = createAllowPredicateFromDenyList( + settingsUpdate.model_deny_list, + settingsUpdate.provider_deny_list + ); if (!(await isAllowed(currentSettings.default_model))) { // Clear default_model if it's no longer allowed @@ -272,10 +277,17 @@ export const organizationsSettingsRouter = createTRPCRouter({ }); } - // Validate default_model against existing model_deny_list + // Validate default_model against existing model_deny_list and provider_deny_list const existingDeniedModels = existingOrg.settings?.model_deny_list; - if (existingDeniedModels && existingDeniedModels.length > 0) { - const isAllowed = createAllowPredicateFromDenyList(existingDeniedModels); + const existingDeniedProviders = existingOrg.settings?.provider_deny_list; + if ( + (existingDeniedModels && existingDeniedModels.length > 0) || + (existingDeniedProviders && existingDeniedProviders.length > 0) + ) { + const isAllowed = createAllowPredicateFromDenyList( + existingDeniedModels, + existingDeniedProviders + ); if (default_model && !(await isAllowed(default_model))) { throw new TRPCError({ From 333aff89173bb358f22cedce0744b68fd6bed4df Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 6 Mar 2026 15:53:53 +0100 Subject: [PATCH 14/14] Move check earlier --- .../OrganizationProvidersAndModelsConfigurationCard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx index c3c4c711b1..28cdccf394 100644 --- a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx +++ b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx @@ -34,6 +34,10 @@ export function computeProviderSelectionsForSummaryCard(params: { modelDenyList: string[]; }): ProviderSelection[] | null { const { openRouterProviders, providerDenyList, modelDenyList } = params; + // If both deny lists are empty, there are no restrictions + if (providerDenyList.length === 0 && modelDenyList.length === 0) { + return null; + } const providerDenySet = new Set(providerDenyList); const modelDenySet = new Set(modelDenyList.map(id => normalizeModelId(id))); @@ -55,11 +59,6 @@ export function computeProviderSelectionsForSummaryCard(params: { } } - // If both deny lists are empty, there are no restrictions - if (providerDenyList.length === 0 && modelDenyList.length === 0) { - return null; - } - // Empty array means restrictions exist but nothing survived — distinct from null ("no restrictions") return selections.length > 0 ? selections : []; }