diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index d982fc24e3..d6acedb09e 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -125,10 +125,11 @@ export const OrganizationPlanSchema = z.enum(['teams', 'enterprise']); export type OrganizationPlan = z.infer; const OrganizationSettingsSchema = z.object({ + /** @deprecated use model_deny_list instead. delete if this is still here May 2026 */ model_allow_list: z.array(z.string()).optional(), + /** @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(), diff --git a/src/app/api/organizations/[id]/defaults/route.test.ts b/src/app/api/organizations/[id]/defaults/route.test.ts index 8549e25199..7d10d6079d 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], }, }, }, @@ -177,8 +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.", }); - 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 dcb30175b6..f82effc914 100644 --- a/src/app/api/organizations/[id]/defaults/route.ts +++ b/src/app/api/organizations/[id]/defaults/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; import type { NextRequest } from 'next/server'; -import { preferredModels, PRIMARY_DEFAULT_MODEL } from '@/lib/models'; +import { PRIMARY_DEFAULT_MODEL } 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'; import { KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model'; @@ -27,9 +27,10 @@ export async function GET( // Get organization's default model setting let defaultModel = organization.settings?.default_model; - const allowList = organization.settings?.model_allow_list; + const modelDenyList = organization.settings?.model_deny_list; + const providerDenyList = organization.settings?.provider_deny_list; - const isAllowed = createProviderAwareModelAllowPredicate(allowList ?? []); + const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList); const findFirstAllowedModel = async (modelIds: readonly string[]) => { for (const modelId of modelIds) { @@ -71,34 +72,29 @@ 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 (!allowList?.length) { - defaultModel = PRIMARY_DEFAULT_MODEL; - } else { - const firstConcreteAllowedModel = allowList.find(modelId => !modelId.endsWith('/*')); - defaultModel = firstConcreteAllowedModel; + if (!modelDenyList?.length && !providerDenyList?.length) { + // No restrictions - use PRIMARY_DEFAULT_MODEL directly + defaultModel = PRIMARY_DEFAULT_MODEL; + } else { + defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL]); + + if (!defaultModel) { + defaultModel = await findFirstAllowedModelFromDbSnapshot(); } - } - - if (!defaultModel && allowList?.length) { - defaultModel = await findFirstAllowedModel(preferredModels); - } - - if (!defaultModel && allowList?.length) { - defaultModel = await findFirstAllowedModelFromDbSnapshot(); - } - if (!defaultModel && allowList?.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 available — all models are blocked by this organization's deny list.", + }, + { status: 409 } + ); + } } } 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 ( diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.test.ts index c4bae81712..b04a4ff277 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 empty array when all providers are denied (distinct from null which means no restrictions)', () => { 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).toEqual([]); + }); + + 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); deny list is non-empty so [] not null + expect(selections).toEqual([]); }); }); diff --git a/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx b/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx index c56915a064..28cdccf394 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; @@ -30,76 +30,37 @@ 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; + // 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))); 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 || - isModelAllowedProviderAwareClient(model.slug, modelAllowList, openRouterProviders) - ); - }) - .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 isModelAllowedProviderAwareClient(model.slug, modelAllowList, openRouterProviders); - }) - .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, + }); } } - 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({ @@ -119,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/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.
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 c6445c9565..52e44503da 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 = { @@ -25,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( @@ -72,199 +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); - const isAllowed = isModelAllowedProviderAwareClient( - normalizedModelId, - allowListArray, - openRouterProviders - ); - if (isAllowed) { + 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); + denySet.add(providerSlug); } - - const nextProviderAllowList = canonicalizeProviderAllowList([...allowSet]); - if ( - hadAllProvidersInitially && - nextProviderAllowList.length === allProviderSlugsWithEndpoints.length - ) { - return { nextProviderAllowList: [], nextModelAllowList }; - } - - 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 60aa46778e..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 { isModelAllowedProviderAwareClient } from '@/lib/model-allow.client'; 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[]; }; @@ -23,8 +22,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; @@ -37,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 => - isModelAllowedProviderAwareClient(modelId, savedModelAllowList, openRouterProviders) - ).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) - ); - } 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[]) => { @@ -103,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/integrations/discord-service.ts b/src/lib/integrations/discord-service.ts index 6508d2cd23..2a34706e28 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 { KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model'; // Default model for Discord integrations - mirrors the Slack default @@ -364,9 +364,10 @@ 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 || []; + 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 b2702028bd..b94c55319a 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 { KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model'; // Default model for Slack integrations - separate from the global platform default @@ -478,9 +478,10 @@ 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 || []; + 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/llm-proxy-helpers.test.ts b/src/lib/llm-proxy-helpers.test.ts index 03e80cd1be..0079a7fd5d 100644 --- a/src/lib/llm-proxy-helpers.test.ts +++ b/src/lib/llm-proxy-helpers.test.ts @@ -6,13 +6,12 @@ import { 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', }); @@ -20,92 +19,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', @@ -113,52 +82,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)', () => { @@ -166,7 +104,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 }); @@ -175,40 +113,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(); @@ -243,20 +181,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 34136219f8..a437d23485 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 { extraRequiredProvider } from '@/lib/models'; import { getFraudDetectionHeaders } from '@/lib/utils'; import { normalizeProjectId } from '@/lib/normalizeProjectId'; import { getXKiloCodeVersionNumber } from '@/lib/userAgent'; @@ -294,40 +293,25 @@ 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.some(entry => normalizeModelId(entry) === normalizedModelId) + ) { + return { error: modelNotAllowedResponse() }; } } - 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 requiredProvider = extraRequiredProvider(normalizedModelId); - if (requiredProvider && !providerAllowList.includes(requiredProvider)) { - console.error(`This FREE model requires the provider: ${requiredProvider}`); - return { error: modelNotAllowedResponse() }; - } - providerConfig.only = providerAllowList; + if (params.organizationPlan === 'enterprise' && providerDenyList && providerDenyList.length > 0) { + providerConfig.ignore = providerDenyList; } // Setting this only if it's set as an override on the organization settings 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 ca9b29dd6f..0000000000 --- a/src/lib/model-allow.client.ts +++ /dev/null @@ -1,82 +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). - */ -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 - ); -} 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 3010e396cf..6edf05d3e9 100644 --- a/src/lib/model-allow.server.ts +++ b/src/lib/model-allow.server.ts @@ -1,82 +1,26 @@ 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; -}; +import { getProviderSlugsForModel } from '@/lib/providers/openrouter/models-by-provider-index.server'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; -export function createProviderAwareModelAllowPredicate( - allowList: string[], - options?: ProviderAwareAllowPredicateOptions +export function createAllowPredicateFromDenyList( + modelDenyList: string[] | undefined, + providerDenyList: string[] | undefined ): ProviderAwareAllowPredicate { - if (allowList.length === 0) { - return async () => true; - } - - const { allowListSet, wildcardProviderSlugs } = prepareModelAllowList(allowList); - - const getProvidersForModel = options?.getProviderSlugsForModel ?? getProviderSlugsForModel; - + const modelDenySet = new Set(modelDenyList?.map(normalizeModelId)); + const providerDenySet = new Set(providerDenyList); return async (modelId: string): Promise => { const normalizedModelId = normalizeModelId(modelId); - - if (isAllowedByExactOrNamespaceWildcard(normalizedModelId, allowListSet)) { - return true; - } - - // 3) Provider-membership wildcard match - if (wildcardProviderSlugs.size === 0) { + if (modelDenySet.has(normalizedModelId)) { return false; } - - const providersForModel = await getProvidersForModel(normalizedModelId); - return isAllowedByProviderMembershipWildcard(providersForModel, wildcardProviderSlugs); - }; -} - -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); + if (providerDenySet.size > 0) { + const providerSlugs = await getProviderSlugsForModel(normalizedModelId); + if (providerSlugs.size > 0 && [...providerSlugs].every(slug => providerDenySet.has(slug))) { + return false; } } - } - return { model_deny_list: [...model_deny_list], provider_deny_list: [...provider_deny_list] }; + return true; + }; } 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/models.ts b/src/lib/models.ts index ba8b823f5c..ea7b78ed70 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -72,10 +72,6 @@ function isOpenRouterStealthModel(model: string): boolean { return model.startsWith('openrouter/') && (model.endsWith('-alpha') || model.endsWith('-beta')); } -export function extraRequiredProvider(model: string) { - return kiloFreeModels.find(m => m.public_id === model)?.inference_provider; -} - export function isDeadFreeModel(model: string): boolean { return !!kiloFreeModels.find(m => m.public_id === model && !m.is_enabled); } 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/lib/providers/index.ts b/src/lib/providers/index.ts index 4197021ab3..1e9be5d247 100644 --- a/src/lib/providers/index.ts +++ b/src/lib/providers/index.ts @@ -242,7 +242,11 @@ export function applyProviderSpecificLogic( if (kiloFreeModel) { requestToMutate.model = kiloFreeModel.internal_id; if (kiloFreeModel.inference_provider) { - requestToMutate.provider = { only: [kiloFreeModel.inference_provider] }; + if (requestToMutate.provider) { + requestToMutate.provider.only = [kiloFreeModel.inference_provider]; + } else { + requestToMutate.provider = { only: [kiloFreeModel.inference_provider] }; + } } } 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 441437d3c7..6b54bff041 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; diff --git a/src/lib/slack-bot/model-allow-list.ts b/src/lib/slack-bot/model-allow-list.ts index f75d2d5656..1fc74edac7 100644 --- a/src/lib/slack-bot/model-allow-list.ts +++ b/src/lib/slack-bot/model-allow-list.ts @@ -1,10 +1,10 @@ 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. - * 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, @@ -15,14 +15,15 @@ export async function getDefaultAllowedModel( return globalDefault; } - const modelAllowList = organization.settings?.model_allow_list || []; + const modelDenyList = organization.settings?.model_deny_list || []; + const providerDenyList = organization.settings?.provider_deny_list || []; // If no restrictions, use global default - if (modelAllowList.length === 0) { + if (modelDenyList.length === 0 && providerDenyList.length === 0) { return globalDefault; } - const isAllowed = createProviderAwareModelAllowPredicate(modelAllowList); + const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList); // Check if the organization's default model is allowed const orgDefaultModel = organization.settings?.default_model; @@ -41,16 +42,10 @@ export async function getDefaultAllowedModel( } } - // Fall back to the first non-wildcard model in the allow list - const firstNonWildcard = modelAllowList.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:', - modelAllowList + '[SlackBot] No allowed model found; deny list blocks all preferred models:', + modelDenyList ); return globalDefault; } diff --git a/src/routers/organizations/organization-settings-router.test.ts b/src/routers/organizations/organization-settings-router.test.ts index 35db95888f..6262622b0a 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,25 @@ 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']); + // 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_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 +284,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 +297,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 +306,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 +327,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 () => { + it('should reject default_model if it is in the deny 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 () => { - 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 +523,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 +536,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 +547,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 +560,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 dc8f09e81f..5d89a48352 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 } 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'; } /** @@ -97,9 +92,9 @@ function createDefaultModelDiffMessage( return 'Updated default model'; } -const UpdateAllowListsInputSchema = OrganizationIdInputSchema.extend({ - model_allow_list: z.array(z.string()).optional(), - provider_allow_list: z.array(z.string()).optional(), +const UpdateDenyListsInputSchema = OrganizationIdInputSchema.extend({ + model_deny_list: z.array(z.string()).optional(), + provider_deny_list: z.array(z.string()).optional(), }); const UpdateDefaultModelInputSchema = OrganizationIdInputSchema.extend({ @@ -158,17 +153,19 @@ export const organizationsSettingsRouter = createTRPCRouter({ }); } - let allowedModels: string[] | undefined; + let deniedModels: string[] | undefined; + let deniedProviders: string[] | undefined; - if (organization.plan === 'enterprise' && organization?.settings?.model_allow_list) { - allowedModels = organization.settings.model_allow_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 (allowedModels) { - const isAllowed = createProviderAwareModelAllowPredicate(allowedModels); + if (deniedModels?.length || deniedProviders?.length) { + const isAllowed = createAllowPredicateFromDenyList(deniedModels, deniedProviders); const models: OpenRouterModel[] = []; for (const model of responseData.data) { if (await isAllowed(model.id)) { @@ -187,10 +184,10 @@ export const organizationsSettingsRouter = createTRPCRouter({ }), updateAllowLists: organizationOwnerProcedure - .input(UpdateAllowListsInputSchema) + .input(UpdateDenyListsInputSchema) .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,32 +213,26 @@ 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 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, + settingsUpdate.provider_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 +244,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,10 +277,17 @@ 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); + // Validate default_model against existing model_deny_list and provider_deny_list + const existingDeniedModels = existingOrg.settings?.model_deny_list; + 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({ 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}`); - }); - } -}