From 08779646776ad63758a60a353ea8ad561c29b125 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 16:56:27 +0200 Subject: [PATCH 1/9] fix(organizations): reject model aliases outside snapshot --- apps/web/src/lib/model-allow.server.test.ts | 33 ++++++++++- apps/web/src/lib/model-allow.server.ts | 23 ++++++-- .../effective-model-access.server.test.ts | 58 +++++++++++++++++-- .../model-access/model-access.server.ts | 22 ++++++- .../legacy-model-restrictions-parity.test.ts | 3 +- .../lib/organizations/model-restrictions.ts | 7 ++- .../organization-settings-router.test.ts | 20 +++++++ .../organization-settings-router.ts | 3 + 8 files changed, 150 insertions(+), 19 deletions(-) diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index 0046726171..fc1a53bcf1 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -53,12 +53,41 @@ describe('model access predicates', () => { await expect(isAllowed('openai/gpt-4o')).resolves.toBe(true); }); - test('provider allow list permits models without OpenRouter provider metadata', async () => { + test('provider allow list denies models missing from the current snapshot', async () => { const isAllowed = createAllowPredicateFromProviderAllowList(undefined, ['openai'], lookup({})); - await expect(isAllowed('custom-llm-id')).resolves.toBe(true); + await expect(isAllowed('grok-4.5')).resolves.toBe(false); }); + test('enterprise deny lists require models to exist in the current snapshot', async () => { + const isAllowed = createAllowPredicateFromRestrictions( + { + requireModelInCurrentSnapshot: true, + modelDenyList: ['x-ai/grok-4.5'], + }, + lookup({ 'x-ai/grok-4.6': ['x-ai'] }) + ); + + await expect(isAllowed('grok-4.5')).resolves.toBe(false); + await expect(isAllowed('x-ai/grok-4.6')).resolves.toBe(true); + }); + + test.each(['kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( + 'keeps %s exempt from Enterprise model restrictions', + async modelId => { + const isAllowed = createAllowPredicateFromRestrictions( + { + requireModelInCurrentSnapshot: true, + providerAllowList: [], + modelDenyList: [modelId], + }, + lookup({}) + ); + + await expect(isAllowed(modelId)).resolves.toBe(true); + } + ); + test('provider allow list still applies model deny list', async () => { const isAllowed = createAllowPredicateFromProviderAllowList( ['openai/gpt-4o'], diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index 32fa72c9f7..c9f2bb5982 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -1,16 +1,25 @@ import 'server-only'; -import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; +import { CUSTOM_LLM_PREFIX, normalizeModelId } from '@/lib/ai-gateway/model-utils'; +import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; export type ModelRestrictions = { + requireModelInCurrentSnapshot?: boolean; providerAllowList?: string[]; modelDenyList: string[]; }; export type ProviderLookup = (modelId: string) => Promise>; +export async function isModelRestrictionExempt(modelId: string): Promise { + const requestedModelId = modelId.trim().toLowerCase(); + if (requestedModelId.startsWith(CUSTOM_LLM_PREFIX)) return true; + const directByokModel = await getDirectByokModel(requestedModelId); + return directByokModel.provider !== null && directByokModel.model !== null; +} + export function hasActiveModelRestrictions(restrictions: ModelRestrictions): boolean { return restrictions.providerAllowList !== undefined || restrictions.modelDenyList.length > 0; } @@ -18,20 +27,23 @@ export function hasActiveModelRestrictions(restrictions: ModelRestrictions): boo export function createAllowPredicateFromProviderAllowList( modelDenyList: string[] | undefined, providerAllowList: string[] | undefined, - providerLookup: ProviderLookup = getProviderSlugsForModel + providerLookup: ProviderLookup = getProviderSlugsForModel, + requireModelInCurrentSnapshot = providerAllowList !== undefined ): ProviderAwareAllowPredicate { const modelDenySet = new Set(modelDenyList?.map(normalizeModelId)); const providerAllowSet = providerAllowList ? new Set(providerAllowList) : undefined; return async (modelId: string): Promise => { const normalizedModelId = normalizeModelId(modelId); + if (await isModelRestrictionExempt(modelId)) return true; if (modelDenySet.has(normalizedModelId)) { return false; } - if (!providerAllowSet) { + if (!providerAllowSet && !requireModelInCurrentSnapshot) { return true; } const providerSlugs = await providerLookup(normalizedModelId); - if (providerSlugs.size === 0) return true; + if (providerSlugs.size === 0) return false; + if (!providerAllowSet) return true; return [...providerSlugs].some(slug => providerAllowSet.has(slug)); }; } @@ -43,6 +55,7 @@ export function createAllowPredicateFromRestrictions( return createAllowPredicateFromProviderAllowList( restrictions.modelDenyList, restrictions.providerAllowList, - providerLookup + providerLookup, + restrictions.requireModelInCurrentSnapshot ); } diff --git a/apps/web/src/lib/organizations/effective-model-access.server.test.ts b/apps/web/src/lib/organizations/effective-model-access.server.test.ts index 13d063d48c..e314173974 100644 --- a/apps/web/src/lib/organizations/effective-model-access.server.test.ts +++ b/apps/web/src/lib/organizations/effective-model-access.server.test.ts @@ -38,6 +38,15 @@ function context( }; } +const currentSnapshotLookup = async (modelId: string) => + new Set( + { + 'anthropic/claude': ['anthropic'], + 'openai/gpt-4o': ['openai'], + 'openai/o3': ['openai'], + }[modelId] ?? [] + ); + describe('effective organization model access', () => { it('preserves current organization access outside Enterprise', async () => { const policy = evaluateEffectiveModelAccessPolicy( @@ -46,14 +55,45 @@ describe('effective organization model access', () => { defaultPolicies: [], }) ); - expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true); + expect( + (await getEffectiveModelDecision(policy, 'anthropic/claude', async () => new Set())).allowed + ).toBe(true); }); + it('denies Enterprise model aliases missing from the current snapshot', async () => { + const policy = evaluateEffectiveModelAccessPolicy( + context({ + organization: { + ...context().organization, + settings: { model_deny_list: ['x-ai/grok-4.5'] }, + }, + defaultPolicies: [{ type: 'model_access', data: { mode: 'all' } }], + }) + ); + + const decision = await getEffectiveModelDecision(policy, 'grok-4.5', async () => new Set()); + + expect(decision).toEqual({ allowed: false, denialSource: 'organization_model' }); + }); + + it.each(['kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( + 'keeps %s exempt from effective Enterprise restrictions', + async modelId => { + const policy = evaluateEffectiveModelAccessPolicy(context()); + + await expect( + getEffectiveModelDecision(policy, modelId, async () => new Set()) + ).resolves.toEqual({ allowed: true }); + } + ); + it('preserves organization access when no model access policy is configured', async () => { const policy = evaluateEffectiveModelAccessPolicy( context({ defaultPolicies: [], groupPolicies: [] }) ); - expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true); + expect( + (await getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup)).allowed + ).toBe(true); expect((await getEffectiveModelDecision(policy, 'openai/o3')).allowed).toBe(false); }); @@ -87,15 +127,21 @@ describe('effective organization model access', () => { ], }) ); - expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true); - expect((await getEffectiveModelDecision(policy, 'openai/gpt-4o')).allowed).toBe(false); + expect( + (await getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup)).allowed + ).toBe(true); + expect( + (await getEffectiveModelDecision(policy, 'openai/gpt-4o', currentSnapshotLookup)).allowed + ).toBe(false); }); it('lets all dominate selected and none within the organization ceiling', async () => { const policy = evaluateEffectiveModelAccessPolicy( context({ groupPolicies: [[{ type: 'model_access', data: { mode: 'all' } }]] }) ); - expect((await getEffectiveModelDecision(policy, 'anthropic/claude')).allowed).toBe(true); + expect( + (await getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup)).allowed + ).toBe(true); expect((await getEffectiveModelDecision(policy, 'openai/o3')).allowed).toBe(false); }); @@ -117,7 +163,7 @@ describe('effective organization model access', () => { 'unknown/model', async () => new Set() ); - expect(decision).toMatchObject({ allowed: false, denialSource: 'group_provider' }); + expect(decision).toMatchObject({ allowed: false, denialSource: 'organization_model' }); }); it('intersects provider-derived grants with the organization ceiling', async () => { diff --git a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts index 11321874ce..bd75618aa0 100644 --- a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts +++ b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts @@ -8,6 +8,7 @@ import { desc, eq } from 'drizzle-orm'; import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; +import { isModelRestrictionExempt } from '@/lib/model-allow.server'; import { db } from '@/lib/drizzle'; import { getOrganizationGroupPolicyContext, @@ -15,6 +16,7 @@ import { } from '@/lib/organizations/organization-group-policy-context.server'; export type EffectiveOrganizationModelPolicy = { + requireModelInCurrentSnapshot: boolean; organizationModelDenyList: string[]; organizationProviderCeiling?: string[]; memberGrant: @@ -47,9 +49,12 @@ export function evaluateEffectiveModelAccessPolicy( const organizationProviderCeiling = organizationRestrictionsEnabled ? context.organization.settings.provider_allow_list : undefined; + const requireModelInCurrentSnapshot = + organizationModelDenyList.length > 0 || organizationProviderCeiling !== undefined; if (!organizationRestrictionsEnabled) { return { + requireModelInCurrentSnapshot: false, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -63,6 +68,7 @@ export function evaluateEffectiveModelAccessPolicy( .filter(policy => policy.type === 'model_access'); if (policies.length === 0 || policies.some(policy => policy.data.mode === 'all')) { return { + requireModelInCurrentSnapshot, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -73,6 +79,7 @@ export function evaluateEffectiveModelAccessPolicy( const selectedPolicies = policies.filter(policy => policy.data.mode === 'selected'); return { + requireModelInCurrentSnapshot, organizationModelDenyList, organizationProviderCeiling, memberGrant: { @@ -105,18 +112,27 @@ export async function getEffectiveModelDecision( providerLookup: ProviderLookup = getProviderSlugsForModel ): Promise { const normalizedModelId = normalizeModelId(modelId); + if (await isModelRestrictionExempt(modelId)) { + return { allowed: true }; + } if (policy.organizationModelDenyList.includes(normalizedModelId)) { return { allowed: false, denialSource: 'organization_model' }; } + const currentModelProviders = policy.requireModelInCurrentSnapshot + ? await providerLookup(normalizedModelId) + : undefined; + if (currentModelProviders?.size === 0) { + return { allowed: false, denialSource: 'organization_model' }; + } const organizationRoutes = policy.organizationProviderCeiling ? new Set(policy.organizationProviderCeiling) : undefined; async function decisionWithinOrganizationCeiling(): Promise { if (!organizationRoutes) return { allowed: true }; - const modelProviders = await providerLookup(normalizedModelId); + const modelProviders = currentModelProviders ?? (await providerLookup(normalizedModelId)); if (modelProviders.size === 0) { - return { allowed: true, eligibleProviderRoutes: organizationRoutes }; + return { allowed: false, denialSource: 'organization_model' }; } const eligibleProviderRoutes = new Set( [...modelProviders].filter(provider => organizationRoutes.has(provider)) @@ -135,7 +151,7 @@ export async function getEffectiveModelDecision( if (policy.memberGrant.providerAllowList.length === 0) { return { allowed: false, denialSource: 'no_grant' }; } - const modelProviders = await providerLookup(normalizedModelId); + const modelProviders = currentModelProviders ?? (await providerLookup(normalizedModelId)); if (modelProviders.size === 0) { return { allowed: false, denialSource: 'group_provider' }; } diff --git a/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts b/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts index 82ca83ef5c..1d69f16d0b 100644 --- a/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts +++ b/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts @@ -121,7 +121,7 @@ describe('legacy organization restrictions with no groups or policies', () => { 'denied/model': false, 'allowed/model': true, 'blocked-provider/model': false, - 'unknown-routes/model': true, + 'unknown-routes/model': false, }); // Reading policy must not materialize a settings row or invent a revision. @@ -141,6 +141,7 @@ describe('legacy organization restrictions with no groups or policies', () => { const { legacy } = await expectParityWithLegacyPredicate(organization, owner.id); expect(legacy['denied/model']).toBe(false); expect(legacy['blocked-provider/model']).toBe(true); + expect(legacy['unknown-routes/model']).toBe(false); }); it('matches the pre-groups predicate for a provider allow list only', async () => { diff --git a/apps/web/src/lib/organizations/model-restrictions.ts b/apps/web/src/lib/organizations/model-restrictions.ts index 6fa41bae03..89dbfe9c33 100644 --- a/apps/web/src/lib/organizations/model-restrictions.ts +++ b/apps/web/src/lib/organizations/model-restrictions.ts @@ -6,8 +6,11 @@ export function getEffectiveModelRestrictions(organization: Organization): Model if (organization.plan !== 'enterprise') { return { modelDenyList: [] }; } + const providerAllowList = organization.settings?.provider_allow_list; + const modelDenyList = organization.settings?.model_deny_list ?? []; return { - providerAllowList: organization.settings?.provider_allow_list, - modelDenyList: organization.settings?.model_deny_list ?? [], + requireModelInCurrentSnapshot: providerAllowList !== undefined || modelDenyList.length > 0, + providerAllowList, + modelDenyList, }; } diff --git a/apps/web/src/routers/organizations/organization-settings-router.test.ts b/apps/web/src/routers/organizations/organization-settings-router.test.ts index 810cf059af..f9b14e440c 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.test.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.test.ts @@ -85,6 +85,15 @@ const mockedIsPublicIdExperimented = isPublicIdExperimented as unknown as jest.M describe('organizations settings trpc router', () => { beforeEach(() => { mockedGetProviderSlugsForModel.mockReset(); + mockedGetProviderSlugsForModel.mockImplementation(async modelId => { + const provider = { + 'anthropic/claude-3-opus': 'anthropic', + 'gpt-3.5-turbo': 'openai', + 'gpt-4': 'openai', + 'openai/gpt-4o': 'openai', + }[modelId]; + return provider ? new Set([provider]) : new Set(); + }); mockedGetEnhancedOpenRouterModels.mockReset(); mockedIsPublicIdExperimented.mockReset(); mockedIsPublicIdExperimented.mockResolvedValue(false); @@ -582,6 +591,17 @@ describe('organizations settings trpc router', () => { expect(result.settings.default_model).toBe('any-model'); }); + it('keeps custom LLM defaults exempt from Enterprise model restrictions', async () => { + const caller = await createCallerForUser(owner.id); + + const result = await caller.organizations.settings.updateDefaultModel({ + organizationId: orgWithModelDenyList.id, + default_model: 'kilo-internal/private-model', + }); + + expect(result.settings.default_model).toBe('kilo-internal/private-model'); + }); + it('should throw UNAUTHORIZED error for non-owner users', async () => { const caller = await createCallerForUser(member.id); diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index 3f333d06ff..977592e3ed 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -380,6 +380,9 @@ export const organizationsSettingsRouter = createTRPCRouter({ currentSettings.default_model !== ORG_AUTO_MODEL.id ) { const isAllowed = createAllowPredicateFromRestrictions({ + requireModelInCurrentSnapshot: + settingsUpdate.provider_allow_list !== undefined || + (settingsUpdate.model_deny_list?.length ?? 0) > 0, providerAllowList: settingsUpdate.provider_allow_list, modelDenyList: settingsUpdate.model_deny_list ?? [], }); From aa076fadc72e6a910f81870046a0ad8b1c3c4165 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 17:10:21 +0200 Subject: [PATCH 2/9] refactor(organizations): derive snapshot enforcement --- apps/web/src/lib/model-allow.server.test.ts | 8 +++++--- apps/web/src/lib/model-allow.server.ts | 11 +++-------- .../model-access/model-access.server.ts | 11 +++-------- apps/web/src/lib/organizations/model-restrictions.ts | 7 ++----- .../organizations/organization-settings-router.ts | 3 --- 5 files changed, 13 insertions(+), 27 deletions(-) diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index fc1a53bcf1..3bdae2216c 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -11,7 +11,11 @@ function lookup(map: Record): ProviderLookup { describe('model access predicates', () => { test('undefined provider allow list only applies model deny list', async () => { - const isAllowed = createAllowPredicateFromProviderAllowList(['openai/gpt-4o'], undefined); + const isAllowed = createAllowPredicateFromProviderAllowList( + ['openai/gpt-4o'], + undefined, + lookup({ 'anthropic/claude-3-opus': ['anthropic'] }) + ); await expect(isAllowed('openai/gpt-4o')).resolves.toBe(false); await expect(isAllowed('anthropic/claude-3-opus')).resolves.toBe(true); @@ -62,7 +66,6 @@ describe('model access predicates', () => { test('enterprise deny lists require models to exist in the current snapshot', async () => { const isAllowed = createAllowPredicateFromRestrictions( { - requireModelInCurrentSnapshot: true, modelDenyList: ['x-ai/grok-4.5'], }, lookup({ 'x-ai/grok-4.6': ['x-ai'] }) @@ -77,7 +80,6 @@ describe('model access predicates', () => { async modelId => { const isAllowed = createAllowPredicateFromRestrictions( { - requireModelInCurrentSnapshot: true, providerAllowList: [], modelDenyList: [modelId], }, diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index c9f2bb5982..62a7c26a47 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -6,7 +6,6 @@ import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/ export type ProviderAwareAllowPredicate = (modelId: string) => Promise; export type ModelRestrictions = { - requireModelInCurrentSnapshot?: boolean; providerAllowList?: string[]; modelDenyList: string[]; }; @@ -27,20 +26,17 @@ export function hasActiveModelRestrictions(restrictions: ModelRestrictions): boo export function createAllowPredicateFromProviderAllowList( modelDenyList: string[] | undefined, providerAllowList: string[] | undefined, - providerLookup: ProviderLookup = getProviderSlugsForModel, - requireModelInCurrentSnapshot = providerAllowList !== undefined + providerLookup: ProviderLookup = getProviderSlugsForModel ): ProviderAwareAllowPredicate { const modelDenySet = new Set(modelDenyList?.map(normalizeModelId)); const providerAllowSet = providerAllowList ? new Set(providerAllowList) : undefined; return async (modelId: string): Promise => { const normalizedModelId = normalizeModelId(modelId); + if (!providerAllowSet && modelDenySet.size === 0) return true; if (await isModelRestrictionExempt(modelId)) return true; if (modelDenySet.has(normalizedModelId)) { return false; } - if (!providerAllowSet && !requireModelInCurrentSnapshot) { - return true; - } const providerSlugs = await providerLookup(normalizedModelId); if (providerSlugs.size === 0) return false; if (!providerAllowSet) return true; @@ -55,7 +51,6 @@ export function createAllowPredicateFromRestrictions( return createAllowPredicateFromProviderAllowList( restrictions.modelDenyList, restrictions.providerAllowList, - providerLookup, - restrictions.requireModelInCurrentSnapshot + providerLookup ); } diff --git a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts index bd75618aa0..1b56ca0f00 100644 --- a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts +++ b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts @@ -16,7 +16,6 @@ import { } from '@/lib/organizations/organization-group-policy-context.server'; export type EffectiveOrganizationModelPolicy = { - requireModelInCurrentSnapshot: boolean; organizationModelDenyList: string[]; organizationProviderCeiling?: string[]; memberGrant: @@ -49,12 +48,8 @@ export function evaluateEffectiveModelAccessPolicy( const organizationProviderCeiling = organizationRestrictionsEnabled ? context.organization.settings.provider_allow_list : undefined; - const requireModelInCurrentSnapshot = - organizationModelDenyList.length > 0 || organizationProviderCeiling !== undefined; - if (!organizationRestrictionsEnabled) { return { - requireModelInCurrentSnapshot: false, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -68,7 +63,6 @@ export function evaluateEffectiveModelAccessPolicy( .filter(policy => policy.type === 'model_access'); if (policies.length === 0 || policies.some(policy => policy.data.mode === 'all')) { return { - requireModelInCurrentSnapshot, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -79,7 +73,6 @@ export function evaluateEffectiveModelAccessPolicy( const selectedPolicies = policies.filter(policy => policy.data.mode === 'selected'); return { - requireModelInCurrentSnapshot, organizationModelDenyList, organizationProviderCeiling, memberGrant: { @@ -118,7 +111,9 @@ export async function getEffectiveModelDecision( if (policy.organizationModelDenyList.includes(normalizedModelId)) { return { allowed: false, denialSource: 'organization_model' }; } - const currentModelProviders = policy.requireModelInCurrentSnapshot + const hasOrganizationRestrictions = + policy.organizationModelDenyList.length > 0 || policy.organizationProviderCeiling !== undefined; + const currentModelProviders = hasOrganizationRestrictions ? await providerLookup(normalizedModelId) : undefined; if (currentModelProviders?.size === 0) { diff --git a/apps/web/src/lib/organizations/model-restrictions.ts b/apps/web/src/lib/organizations/model-restrictions.ts index 89dbfe9c33..6fa41bae03 100644 --- a/apps/web/src/lib/organizations/model-restrictions.ts +++ b/apps/web/src/lib/organizations/model-restrictions.ts @@ -6,11 +6,8 @@ export function getEffectiveModelRestrictions(organization: Organization): Model if (organization.plan !== 'enterprise') { return { modelDenyList: [] }; } - const providerAllowList = organization.settings?.provider_allow_list; - const modelDenyList = organization.settings?.model_deny_list ?? []; return { - requireModelInCurrentSnapshot: providerAllowList !== undefined || modelDenyList.length > 0, - providerAllowList, - modelDenyList, + providerAllowList: organization.settings?.provider_allow_list, + modelDenyList: organization.settings?.model_deny_list ?? [], }; } diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index 977592e3ed..3f333d06ff 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -380,9 +380,6 @@ export const organizationsSettingsRouter = createTRPCRouter({ currentSettings.default_model !== ORG_AUTO_MODEL.id ) { const isAllowed = createAllowPredicateFromRestrictions({ - requireModelInCurrentSnapshot: - settingsUpdate.provider_allow_list !== undefined || - (settingsUpdate.model_deny_list?.length ?? 0) > 0, providerAllowList: settingsUpdate.provider_allow_list, modelDenyList: settingsUpdate.model_deny_list ?? [], }); From af6afb91f8afb80da039724ae014f47f83246202 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 17:15:26 +0200 Subject: [PATCH 3/9] fix(organizations): exempt auto models from restrictions --- apps/web/src/lib/ai-gateway/model-utils.ts | 4 +++- apps/web/src/lib/model-allow.server.test.ts | 2 +- apps/web/src/lib/model-allow.server.ts | 13 +++++++++++-- .../effective-model-access.server.test.ts | 2 +- .../organization-settings-router.test.ts | 19 +++++++++++-------- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/model-utils.ts b/apps/web/src/lib/ai-gateway/model-utils.ts index e1ae38808d..61684d5a27 100644 --- a/apps/web/src/lib/ai-gateway/model-utils.ts +++ b/apps/web/src/lib/ai-gateway/model-utils.ts @@ -9,11 +9,13 @@ * * The names look swapped but are intentional: Kilo Code (the extension) selects * Kilo-hosted models under `kilo/`, while KiloClaw selects them under - * `kilocode/`. `kilo-internal/` is the custom LLM (`custom_llm2`) namespace. + * `kilocode/`. `kilo-internal/` is the custom LLM (`custom_llm2`) namespace, + * while `kilo-auto/` contains virtual routing models. */ export const KILOCODE_KILO_PROVIDER_PREFIX = 'kilo/'; export const KILOCLAW_KILO_PROVIDER_PREFIX = 'kilocode/'; export const CUSTOM_LLM_PREFIX = 'kilo-internal/'; +export const KILO_AUTO_MODEL_PREFIX = 'kilo-auto/'; /** * Normalize a model ID by removing the `:free`, `:exacto`, etc. suffixes if present. diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index 3bdae2216c..e920a48b34 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -75,7 +75,7 @@ describe('model access predicates', () => { await expect(isAllowed('x-ai/grok-4.6')).resolves.toBe(true); }); - test.each(['kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( + test.each(['kilo-auto/balanced', 'kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( 'keeps %s exempt from Enterprise model restrictions', async modelId => { const isAllowed = createAllowPredicateFromRestrictions( diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index 62a7c26a47..9c6308ce57 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -1,5 +1,9 @@ import 'server-only'; -import { CUSTOM_LLM_PREFIX, normalizeModelId } from '@/lib/ai-gateway/model-utils'; +import { + CUSTOM_LLM_PREFIX, + KILO_AUTO_MODEL_PREFIX, + normalizeModelId, +} from '@/lib/ai-gateway/model-utils'; import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; @@ -14,7 +18,12 @@ export type ProviderLookup = (modelId: string) => Promise>; export async function isModelRestrictionExempt(modelId: string): Promise { const requestedModelId = modelId.trim().toLowerCase(); - if (requestedModelId.startsWith(CUSTOM_LLM_PREFIX)) return true; + if ( + requestedModelId.startsWith(CUSTOM_LLM_PREFIX) || + requestedModelId.startsWith(KILO_AUTO_MODEL_PREFIX) + ) { + return true; + } const directByokModel = await getDirectByokModel(requestedModelId); return directByokModel.provider !== null && directByokModel.model !== null; } diff --git a/apps/web/src/lib/organizations/effective-model-access.server.test.ts b/apps/web/src/lib/organizations/effective-model-access.server.test.ts index e314173974..04d5f11f5e 100644 --- a/apps/web/src/lib/organizations/effective-model-access.server.test.ts +++ b/apps/web/src/lib/organizations/effective-model-access.server.test.ts @@ -76,7 +76,7 @@ describe('effective organization model access', () => { expect(decision).toEqual({ allowed: false, denialSource: 'organization_model' }); }); - it.each(['kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( + it.each(['kilo-auto/balanced', 'kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( 'keeps %s exempt from effective Enterprise restrictions', async modelId => { const policy = evaluateEffectiveModelAccessPolicy(context()); diff --git a/apps/web/src/routers/organizations/organization-settings-router.test.ts b/apps/web/src/routers/organizations/organization-settings-router.test.ts index f9b14e440c..af73e3182c 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.test.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.test.ts @@ -591,16 +591,19 @@ describe('organizations settings trpc router', () => { expect(result.settings.default_model).toBe('any-model'); }); - it('keeps custom LLM defaults exempt from Enterprise model restrictions', async () => { - const caller = await createCallerForUser(owner.id); + it.each(['kilo-auto/balanced', 'kilo-internal/private-model'])( + 'keeps %s defaults exempt from Enterprise model restrictions', + async modelId => { + const caller = await createCallerForUser(owner.id); - const result = await caller.organizations.settings.updateDefaultModel({ - organizationId: orgWithModelDenyList.id, - default_model: 'kilo-internal/private-model', - }); + const result = await caller.organizations.settings.updateDefaultModel({ + organizationId: orgWithModelDenyList.id, + default_model: modelId, + }); - expect(result.settings.default_model).toBe('kilo-internal/private-model'); - }); + expect(result.settings.default_model).toBe(modelId); + } + ); it('should throw UNAUTHORIZED error for non-owner users', async () => { const caller = await createCallerForUser(member.id); From 05a60f007e3025c87e29ae2d8ac69108fe29d30e Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 14:52:21 +0200 Subject: [PATCH 4/9] fix(organizations): constrain all enterprise models to snapshot --- apps/web/src/lib/model-allow.server.test.ts | 16 ++++++++ apps/web/src/lib/model-allow.server.ts | 9 ++-- .../effective-model-access.server.test.ts | 18 +++++++- .../model-access/model-access.server.ts | 8 ++-- .../legacy-model-restrictions-parity.test.ts | 15 +++++++ .../lib/organizations/model-restrictions.ts | 3 +- .../organizations/organization-auto-model.ts | 1 + .../organization-settings-router.test.ts | 41 +++++++++++++++---- .../organization-settings-router.ts | 1 + 9 files changed, 95 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index e920a48b34..d91c5c0628 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -66,6 +66,7 @@ describe('model access predicates', () => { test('enterprise deny lists require models to exist in the current snapshot', async () => { const isAllowed = createAllowPredicateFromRestrictions( { + requireModelInCurrentSnapshot: true, modelDenyList: ['x-ai/grok-4.5'], }, lookup({ 'x-ai/grok-4.6': ['x-ai'] }) @@ -75,11 +76,25 @@ describe('model access predicates', () => { await expect(isAllowed('x-ai/grok-4.6')).resolves.toBe(true); }); + test('Enterprise requires snapshot membership without configured restrictions', async () => { + const isAllowed = createAllowPredicateFromRestrictions( + { + requireModelInCurrentSnapshot: true, + modelDenyList: [], + }, + lookup({ 'x-ai/grok-4.6': ['x-ai'] }) + ); + + await expect(isAllowed('grok-4.5')).resolves.toBe(false); + await expect(isAllowed('x-ai/grok-4.6')).resolves.toBe(true); + }); + test.each(['kilo-auto/balanced', 'kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( 'keeps %s exempt from Enterprise model restrictions', async modelId => { const isAllowed = createAllowPredicateFromRestrictions( { + requireModelInCurrentSnapshot: true, providerAllowList: [], modelDenyList: [modelId], }, @@ -103,6 +118,7 @@ describe('model access predicates', () => { test('createAllowPredicateFromRestrictions uses provider allow and model deny lists', async () => { const isAllowed = createAllowPredicateFromRestrictions( { + requireModelInCurrentSnapshot: true, providerAllowList: ['openai'], modelDenyList: ['openai/gpt-4o'], }, diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index 9c6308ce57..f398c22500 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -10,6 +10,7 @@ import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/ export type ProviderAwareAllowPredicate = (modelId: string) => Promise; export type ModelRestrictions = { + requireModelInCurrentSnapshot: boolean; providerAllowList?: string[]; modelDenyList: string[]; }; @@ -35,13 +36,14 @@ export function hasActiveModelRestrictions(restrictions: ModelRestrictions): boo export function createAllowPredicateFromProviderAllowList( modelDenyList: string[] | undefined, providerAllowList: string[] | undefined, - providerLookup: ProviderLookup = getProviderSlugsForModel + providerLookup: ProviderLookup = getProviderSlugsForModel, + requireModelInCurrentSnapshot = false ): ProviderAwareAllowPredicate { const modelDenySet = new Set(modelDenyList?.map(normalizeModelId)); const providerAllowSet = providerAllowList ? new Set(providerAllowList) : undefined; return async (modelId: string): Promise => { const normalizedModelId = normalizeModelId(modelId); - if (!providerAllowSet && modelDenySet.size === 0) return true; + if (!requireModelInCurrentSnapshot && !providerAllowSet && modelDenySet.size === 0) return true; if (await isModelRestrictionExempt(modelId)) return true; if (modelDenySet.has(normalizedModelId)) { return false; @@ -60,6 +62,7 @@ export function createAllowPredicateFromRestrictions( return createAllowPredicateFromProviderAllowList( restrictions.modelDenyList, restrictions.providerAllowList, - providerLookup + providerLookup, + restrictions.requireModelInCurrentSnapshot ); } diff --git a/apps/web/src/lib/organizations/effective-model-access.server.test.ts b/apps/web/src/lib/organizations/effective-model-access.server.test.ts index 04d5f11f5e..2550d352df 100644 --- a/apps/web/src/lib/organizations/effective-model-access.server.test.ts +++ b/apps/web/src/lib/organizations/effective-model-access.server.test.ts @@ -76,6 +76,22 @@ describe('effective organization model access', () => { expect(decision).toEqual({ allowed: false, denialSource: 'organization_model' }); }); + it('requires snapshot membership for Enterprise without configured restrictions', async () => { + const policy = evaluateEffectiveModelAccessPolicy( + context({ + organization: { ...context().organization, settings: {} }, + defaultPolicies: [], + }) + ); + + await expect( + getEffectiveModelDecision(policy, 'grok-4.5', async () => new Set()) + ).resolves.toEqual({ allowed: false, denialSource: 'organization_model' }); + await expect( + getEffectiveModelDecision(policy, 'anthropic/claude', currentSnapshotLookup) + ).resolves.toEqual({ allowed: true }); + }); + it.each(['kilo-auto/balanced', 'kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( 'keeps %s exempt from effective Enterprise restrictions', async modelId => { @@ -87,7 +103,7 @@ describe('effective organization model access', () => { } ); - it('preserves organization access when no model access policy is configured', async () => { + it('allows known snapshot models when no model access policy is configured', async () => { const policy = evaluateEffectiveModelAccessPolicy( context({ defaultPolicies: [], groupPolicies: [] }) ); diff --git a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts index 1b56ca0f00..ed3496b404 100644 --- a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts +++ b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts @@ -16,6 +16,7 @@ import { } from '@/lib/organizations/organization-group-policy-context.server'; export type EffectiveOrganizationModelPolicy = { + requireModelInCurrentSnapshot: boolean; organizationModelDenyList: string[]; organizationProviderCeiling?: string[]; memberGrant: @@ -50,6 +51,7 @@ export function evaluateEffectiveModelAccessPolicy( : undefined; if (!organizationRestrictionsEnabled) { return { + requireModelInCurrentSnapshot: false, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -63,6 +65,7 @@ export function evaluateEffectiveModelAccessPolicy( .filter(policy => policy.type === 'model_access'); if (policies.length === 0 || policies.some(policy => policy.data.mode === 'all')) { return { + requireModelInCurrentSnapshot: true, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -73,6 +76,7 @@ export function evaluateEffectiveModelAccessPolicy( const selectedPolicies = policies.filter(policy => policy.data.mode === 'selected'); return { + requireModelInCurrentSnapshot: true, organizationModelDenyList, organizationProviderCeiling, memberGrant: { @@ -111,9 +115,7 @@ export async function getEffectiveModelDecision( if (policy.organizationModelDenyList.includes(normalizedModelId)) { return { allowed: false, denialSource: 'organization_model' }; } - const hasOrganizationRestrictions = - policy.organizationModelDenyList.length > 0 || policy.organizationProviderCeiling !== undefined; - const currentModelProviders = hasOrganizationRestrictions + const currentModelProviders = policy.requireModelInCurrentSnapshot ? await providerLookup(normalizedModelId) : undefined; if (currentModelProviders?.size === 0) { diff --git a/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts b/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts index 1d69f16d0b..b7435bf5af 100644 --- a/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts +++ b/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts @@ -153,6 +153,21 @@ describe('legacy organization restrictions with no groups or policies', () => { expect(legacy['blocked-provider/model']).toBe(false); }); + it('requires snapshot membership for Enterprise without configured restrictions', async () => { + const { owner, organization } = await seedLegacyOrganization( + 'Legacy Enterprise Snapshot Only', + {} + ); + + const { legacy } = await expectParityWithLegacyPredicate(organization, owner.id); + expect(legacy).toEqual({ + 'denied/model': true, + 'allowed/model': true, + 'blocked-provider/model': true, + 'unknown-routes/model': false, + }); + }); + it('keeps stored restrictions unenforced on Teams, as before', async () => { const { owner, organization } = await seedLegacyOrganization( 'Legacy Teams', diff --git a/apps/web/src/lib/organizations/model-restrictions.ts b/apps/web/src/lib/organizations/model-restrictions.ts index 6fa41bae03..b4507e2f47 100644 --- a/apps/web/src/lib/organizations/model-restrictions.ts +++ b/apps/web/src/lib/organizations/model-restrictions.ts @@ -4,9 +4,10 @@ import type { ModelRestrictions } from '@/lib/model-allow.server'; // Teams plans store deny lists but do not enforce them. export function getEffectiveModelRestrictions(organization: Organization): ModelRestrictions { if (organization.plan !== 'enterprise') { - return { modelDenyList: [] }; + return { requireModelInCurrentSnapshot: false, modelDenyList: [] }; } return { + requireModelInCurrentSnapshot: true, providerAllowList: organization.settings?.provider_allow_list, modelDenyList: organization.settings?.model_deny_list ?? [], }; diff --git a/apps/web/src/lib/organizations/organization-auto-model.ts b/apps/web/src/lib/organizations/organization-auto-model.ts index 12ebad86e8..1d5bcefbd3 100644 --- a/apps/web/src/lib/organizations/organization-auto-model.ts +++ b/apps/web/src/lib/organizations/organization-auto-model.ts @@ -142,6 +142,7 @@ export async function validateOrganizationAutoTarget( } const restrictions = { + requireModelInCurrentSnapshot: organization.plan === 'enterprise', providerAllowList: organization.plan === 'enterprise' ? organization.settings.provider_allow_list : undefined, modelDenyList: diff --git a/apps/web/src/routers/organizations/organization-settings-router.test.ts b/apps/web/src/routers/organizations/organization-settings-router.test.ts index af73e3182c..56e7c563c2 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.test.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.test.ts @@ -350,6 +350,27 @@ describe('organizations settings trpc router', () => { }; } + it('excludes models outside the snapshot without configured restrictions', async () => { + const organization = await createTestOrganization( + 'Snapshot-only Enterprise', + owner.id, + 0, + {}, + false + ); + await addUserToOrganization(organization.id, member.id, 'member'); + mockedGetEnhancedOpenRouterModels.mockResolvedValue({ + data: [makeOpenRouterModel('openai/gpt-4o'), makeOpenRouterModel('openrouter/free')], + } satisfies OpenRouterModelsResponse); + + const caller = await createCallerForUser(member.id); + const result = await caller.organizations.settings.listAvailableModels({ + organizationId: organization.id, + }); + + expect(result.data.map(model => model.id)).toEqual(['openai/gpt-4o']); + }); + it('should exclude models in model_deny_list for enterprise orgs', async () => { const openRouterModelsResponse = { data: [ @@ -576,19 +597,21 @@ describe('organizations settings trpc router', () => { ); }); - it('should allow any model when no access policy is configured', async () => { + it('rejects models outside the snapshot when no access policy is configured', async () => { const caller = await createCallerForUser(owner.id); await updateOrganizationSettings(testOrganization.id, { data_collection: 'allow', }); - const result = await caller.organizations.settings.updateDefaultModel({ - organizationId: testOrganization.id, - default_model: 'any-model', - }); - - expect(result.settings.default_model).toBe('any-model'); + await expect( + caller.organizations.settings.updateDefaultModel({ + organizationId: testOrganization.id, + default_model: 'openrouter/free', + }) + ).rejects.toThrow( + "Default model 'openrouter/free' is not in the organization's allowed models list" + ); }); it.each(['kilo-auto/balanced', 'kilo-internal/private-model'])( @@ -775,10 +798,10 @@ describe('organizations settings trpc router', () => { const result = await caller.organizations.settings.configureOrganizationDefaultBehavior({ organizationId: specificOrg.id, behavior: 'specific', - specific_model: 'any-model', + specific_model: 'openai/gpt-4o', }); - expect(result.settings.default_model).toBe('any-model'); + expect(result.settings.default_model).toBe('openai/gpt-4o'); }); it('sets and clears Organization Auto routes', async () => { diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index 3f333d06ff..127e5cd62e 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -380,6 +380,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ currentSettings.default_model !== ORG_AUTO_MODEL.id ) { const isAllowed = createAllowPredicateFromRestrictions({ + requireModelInCurrentSnapshot: true, providerAllowList: settingsUpdate.provider_allow_list, modelDenyList: settingsUpdate.model_deny_list ?? [], }); From 683720d3dc3ac33cdd0ce602dfebfd6dd6e61111 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 15:13:08 +0200 Subject: [PATCH 5/9] fix(organizations): preserve managed latest aliases --- .../lib/ai-gateway/latest-model-aliases.ts | 29 +++++++++++ .../lib/ai-gateway/llm-proxy-helpers.test.ts | 14 ++++++ .../src/lib/ai-gateway/llm-proxy-helpers.ts | 7 ++- .../vercel/mapModelIdToVercel.test.ts | 48 +++++++++++++++---- .../providers/vercel/mapModelIdToVercel.ts | 32 +++++++++---- apps/web/src/lib/model-allow.server.test.ts | 23 +++++++++ apps/web/src/lib/model-allow.server.ts | 6 +++ .../effective-model-access.server.test.ts | 44 +++++++++++++++++ .../model-access/model-access.server.ts | 22 +++++++-- .../organization-settings-router.test.ts | 22 +++++++++ 10 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/lib/ai-gateway/latest-model-aliases.ts diff --git a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts new file mode 100644 index 0000000000..a60a785c75 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts @@ -0,0 +1,29 @@ +export const CLAUDE_FABLE_LATEST_MODEL_ALIAS = '~anthropic/claude-fable-latest'; +export const CLAUDE_OPUS_LATEST_MODEL_ALIAS = '~anthropic/claude-opus-latest'; +export const CLAUDE_SONNET_LATEST_MODEL_ALIAS = '~anthropic/claude-sonnet-latest'; +export const CLAUDE_HAIKU_LATEST_MODEL_ALIAS = '~anthropic/claude-haiku-latest'; +export const GPT_LATEST_MODEL_ALIAS = '~openai/gpt-latest'; +export const GPT_MINI_LATEST_MODEL_ALIAS = '~openai/gpt-mini-latest'; +export const KIMI_LATEST_MODEL_ALIAS = '~moonshotai/kimi-latest'; +export const GEMINI_PRO_LATEST_MODEL_ALIAS = '~google/gemini-pro-latest'; +export const GEMINI_FLASH_LATEST_MODEL_ALIAS = '~google/gemini-flash-latest'; +export const GROK_LATEST_MODEL_ALIAS = '~x-ai/grok-latest'; + +export const LATEST_MODEL_ALIASES = [ + CLAUDE_FABLE_LATEST_MODEL_ALIAS, + CLAUDE_OPUS_LATEST_MODEL_ALIAS, + CLAUDE_SONNET_LATEST_MODEL_ALIAS, + CLAUDE_HAIKU_LATEST_MODEL_ALIAS, + GPT_LATEST_MODEL_ALIAS, + GPT_MINI_LATEST_MODEL_ALIAS, + KIMI_LATEST_MODEL_ALIAS, + GEMINI_PRO_LATEST_MODEL_ALIAS, + GEMINI_FLASH_LATEST_MODEL_ALIAS, + GROK_LATEST_MODEL_ALIAS, +] as const; + +const latestModelAliasSet = new Set(LATEST_MODEL_ALIASES); + +export function isLatestModelAlias(modelId: string): boolean { + return latestModelAliasSet.has(modelId.trim().toLowerCase()); +} diff --git a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts index 414cdadd63..09b12f27e7 100644 --- a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts +++ b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import type { MicrodollarUsageContext, MicrodollarUsageStats } from './processUsage.types'; import type { GatewayRequest } from './providers/openrouter/types'; +import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from './latest-model-aliases'; let mockInceptionPromoRunning = true; @@ -73,6 +74,19 @@ describe('checkOrganizationModelRestrictions', () => { expect(result.error?.status).toBe(404); }); + it('excludes latest aliases from model deny lists while retaining provider config', () => { + const result = checkOrganizationModelRestrictions({ + modelId: CLAUDE_SONNET_LATEST_MODEL_ALIAS, + settings: { + model_deny_list: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], + provider_allow_list: ['anthropic'], + }, + organizationPlan: 'enterprise', + }); + + expect(result).toEqual({ error: null, providerConfig: { only: ['anthropic'] } }); + }); + it('should allow any model when deny list is empty on enterprise plan', () => { const result = checkOrganizationModelRestrictions({ modelId: 'anthropic/claude-3-opus', diff --git a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts index 91608d5d74..d140c1ef5d 100644 --- a/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts +++ b/apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts @@ -32,6 +32,7 @@ import { getFraudDetectionHeaders, toMicrodollars } from '@/lib/utils'; import { normalizeProjectId } from '@/lib/normalizeProjectId'; import { getXKiloCodeVersionNumber } from '@/lib/userAgent'; import { normalizeModelId } from '@/lib/ai-gateway/providers/openrouter'; +import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; import { createParser, type EventSourceMessage } from 'eventsource-parser'; import { sentryRootSpan } from '../getRootSpan'; import { findKiloExclusiveModel, shouldRedactErrorResponse } from '@/lib/ai-gateway/models'; @@ -476,7 +477,11 @@ export function checkOrganizationModelRestrictions(params: { // Model/provider access restrictions only apply to Enterprise plans. if (params.organizationPlan === 'enterprise') { const modelDenyList = params.settings.model_deny_list; - if (modelDenyList?.some(entry => normalizeModelId(entry) === normalizedModelId)) { + // TODO: Consider removing latest aliases instead of retaining this model-policy exception. + if ( + !isLatestModelAlias(normalizedModelId) && + modelDenyList?.some(entry => normalizeModelId(entry) === normalizedModelId) + ) { return { error: modelNotAllowedResponse() }; } } diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts index 22eb48dff7..b29c19fecf 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts @@ -16,24 +16,52 @@ import { } from '@/lib/ai-gateway/providers/openai'; import { mapModelIdToVercel } from '@/lib/ai-gateway/providers/vercel/mapModelIdToVercel'; import { GROK_CURRENT_VERCEL_MODEL_ID } from '@/lib/ai-gateway/providers/xai'; +import { + CLAUDE_FABLE_LATEST_MODEL_ALIAS, + CLAUDE_HAIKU_LATEST_MODEL_ALIAS, + CLAUDE_OPUS_LATEST_MODEL_ALIAS, + CLAUDE_SONNET_LATEST_MODEL_ALIAS, + GEMINI_FLASH_LATEST_MODEL_ALIAS, + GEMINI_PRO_LATEST_MODEL_ALIAS, + GPT_LATEST_MODEL_ALIAS, + GPT_MINI_LATEST_MODEL_ALIAS, + GROK_LATEST_MODEL_ALIAS, + KIMI_LATEST_MODEL_ALIAS, + LATEST_MODEL_ALIASES, +} from '@/lib/ai-gateway/latest-model-aliases'; describe('mapModelIdToVercel', () => { describe('tilde-prefixed latest aliases', () => { it.each([ - ['~anthropic/claude-fable-latest', CLAUDE_FABLE_CURRENT_VERCEL_MODEL_ID], - ['~anthropic/claude-opus-latest', CLAUDE_OPUS_CURRENT_VERCEL_MODEL_ID], - ['~anthropic/claude-sonnet-latest', CLAUDE_SONNET_CURRENT_VERCEL_MODEL_ID], - ['~anthropic/claude-haiku-latest', CLAUDE_HAIKU_CURRENT_VERCEL_MODEL_ID], - ['~openai/gpt-latest', GPT_CURRENT_VERCEL_MODEL_ID], - ['~openai/gpt-mini-latest', GPT_MINI_CURRENT_VERCEL_MODEL_ID], - ['~moonshotai/kimi-latest', KIMI_CURRENT_VERCEL_MODEL_ID], - ['~google/gemini-pro-latest', GEMINI_PRO_CURRENT_VERCEL_MODEL_ID], - ['~google/gemini-flash-latest', GEMINI_FLASH_CURRENT_VERCEL_MODEL_ID], - ['~x-ai/grok-latest', GROK_CURRENT_VERCEL_MODEL_ID], + [CLAUDE_FABLE_LATEST_MODEL_ALIAS, CLAUDE_FABLE_CURRENT_VERCEL_MODEL_ID], + [CLAUDE_OPUS_LATEST_MODEL_ALIAS, CLAUDE_OPUS_CURRENT_VERCEL_MODEL_ID], + [CLAUDE_SONNET_LATEST_MODEL_ALIAS, CLAUDE_SONNET_CURRENT_VERCEL_MODEL_ID], + [CLAUDE_HAIKU_LATEST_MODEL_ALIAS, CLAUDE_HAIKU_CURRENT_VERCEL_MODEL_ID], + [GPT_LATEST_MODEL_ALIAS, GPT_CURRENT_VERCEL_MODEL_ID], + [GPT_MINI_LATEST_MODEL_ALIAS, GPT_MINI_CURRENT_VERCEL_MODEL_ID], + [KIMI_LATEST_MODEL_ALIAS, KIMI_CURRENT_VERCEL_MODEL_ID], + [GEMINI_PRO_LATEST_MODEL_ALIAS, GEMINI_PRO_CURRENT_VERCEL_MODEL_ID], + [GEMINI_FLASH_LATEST_MODEL_ALIAS, GEMINI_FLASH_CURRENT_VERCEL_MODEL_ID], + [GROK_LATEST_MODEL_ALIAS, GROK_CURRENT_VERCEL_MODEL_ID], ])('maps %s to the current Vercel model id', (input, expected) => { expect(mapModelIdToVercel(input)).toBe(expected); }); + it('exports every latest alias in one list', () => { + expect(LATEST_MODEL_ALIASES).toEqual([ + CLAUDE_FABLE_LATEST_MODEL_ALIAS, + CLAUDE_OPUS_LATEST_MODEL_ALIAS, + CLAUDE_SONNET_LATEST_MODEL_ALIAS, + CLAUDE_HAIKU_LATEST_MODEL_ALIAS, + GPT_LATEST_MODEL_ALIAS, + GPT_MINI_LATEST_MODEL_ALIAS, + KIMI_LATEST_MODEL_ALIAS, + GEMINI_PRO_LATEST_MODEL_ALIAS, + GEMINI_FLASH_LATEST_MODEL_ALIAS, + GROK_LATEST_MODEL_ALIAS, + ]); + }); + it('does not map a latest alias that is missing the leading tilde', () => { expect(mapModelIdToVercel('anthropic/claude-opus-latest')).toBe( 'anthropic/claude-opus-latest' diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts index fda05dcf44..e1a1465061 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts @@ -16,18 +16,30 @@ import { } from '@/lib/ai-gateway/providers/openai'; import { inferVercelFirstPartyInferenceProviderForModel } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import { GROK_CURRENT_VERCEL_MODEL_ID } from '@/lib/ai-gateway/providers/xai'; +import { + CLAUDE_FABLE_LATEST_MODEL_ALIAS, + CLAUDE_HAIKU_LATEST_MODEL_ALIAS, + CLAUDE_OPUS_LATEST_MODEL_ALIAS, + CLAUDE_SONNET_LATEST_MODEL_ALIAS, + GEMINI_FLASH_LATEST_MODEL_ALIAS, + GEMINI_PRO_LATEST_MODEL_ALIAS, + GPT_LATEST_MODEL_ALIAS, + GPT_MINI_LATEST_MODEL_ALIAS, + GROK_LATEST_MODEL_ALIAS, + KIMI_LATEST_MODEL_ALIAS, +} from '@/lib/ai-gateway/latest-model-aliases'; const vercelModelIdMapping: Record = { - '~anthropic/claude-fable-latest': CLAUDE_FABLE_CURRENT_VERCEL_MODEL_ID, - '~anthropic/claude-opus-latest': CLAUDE_OPUS_CURRENT_VERCEL_MODEL_ID, - '~anthropic/claude-sonnet-latest': CLAUDE_SONNET_CURRENT_VERCEL_MODEL_ID, - '~anthropic/claude-haiku-latest': CLAUDE_HAIKU_CURRENT_VERCEL_MODEL_ID, - '~openai/gpt-latest': GPT_CURRENT_VERCEL_MODEL_ID, - '~openai/gpt-mini-latest': GPT_MINI_CURRENT_VERCEL_MODEL_ID, - '~moonshotai/kimi-latest': KIMI_CURRENT_VERCEL_MODEL_ID, - '~google/gemini-pro-latest': GEMINI_PRO_CURRENT_VERCEL_MODEL_ID, - '~google/gemini-flash-latest': GEMINI_FLASH_CURRENT_VERCEL_MODEL_ID, - '~x-ai/grok-latest': GROK_CURRENT_VERCEL_MODEL_ID, + [CLAUDE_FABLE_LATEST_MODEL_ALIAS]: CLAUDE_FABLE_CURRENT_VERCEL_MODEL_ID, + [CLAUDE_OPUS_LATEST_MODEL_ALIAS]: CLAUDE_OPUS_CURRENT_VERCEL_MODEL_ID, + [CLAUDE_SONNET_LATEST_MODEL_ALIAS]: CLAUDE_SONNET_CURRENT_VERCEL_MODEL_ID, + [CLAUDE_HAIKU_LATEST_MODEL_ALIAS]: CLAUDE_HAIKU_CURRENT_VERCEL_MODEL_ID, + [GPT_LATEST_MODEL_ALIAS]: GPT_CURRENT_VERCEL_MODEL_ID, + [GPT_MINI_LATEST_MODEL_ALIAS]: GPT_MINI_CURRENT_VERCEL_MODEL_ID, + [KIMI_LATEST_MODEL_ALIAS]: KIMI_CURRENT_VERCEL_MODEL_ID, + [GEMINI_PRO_LATEST_MODEL_ALIAS]: GEMINI_PRO_CURRENT_VERCEL_MODEL_ID, + [GEMINI_FLASH_LATEST_MODEL_ALIAS]: GEMINI_FLASH_CURRENT_VERCEL_MODEL_ID, + [GROK_LATEST_MODEL_ALIAS]: GROK_CURRENT_VERCEL_MODEL_ID, 'deepseek/deepseek-v4-flash-latest': 'deepseek/deepseek-v4-flash-0731', 'inclusionai/ling-3.0-flash:free': 'inclusionai/ling-3.0-flash-free', 'mistralai/codestral-2508': 'mistral/codestral', diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index d91c5c0628..a32065ac1a 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -4,6 +4,7 @@ import { createAllowPredicateFromRestrictions, type ProviderLookup, } from '@/lib/model-allow.server'; +import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; function lookup(map: Record): ProviderLookup { return async modelId => new Set(map[modelId] ?? []); @@ -89,6 +90,28 @@ describe('model access predicates', () => { await expect(isAllowed('x-ai/grok-4.6')).resolves.toBe(true); }); + test('latest aliases bypass model restrictions but retain provider availability', async () => { + const withProviders = createAllowPredicateFromRestrictions( + { + requireModelInCurrentSnapshot: true, + providerAllowList: ['anthropic'], + modelDenyList: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], + }, + lookup({}) + ); + const withoutProviders = createAllowPredicateFromRestrictions( + { + requireModelInCurrentSnapshot: true, + providerAllowList: [], + modelDenyList: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], + }, + lookup({}) + ); + + await expect(withProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(true); + await expect(withoutProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(false); + }); + test.each(['kilo-auto/balanced', 'kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( 'keeps %s exempt from Enterprise model restrictions', async modelId => { diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index f398c22500..922f78a319 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -6,6 +6,7 @@ import { } from '@/lib/ai-gateway/model-utils'; import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; +import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; @@ -45,6 +46,11 @@ export function createAllowPredicateFromProviderAllowList( const normalizedModelId = normalizeModelId(modelId); if (!requireModelInCurrentSnapshot && !providerAllowSet && modelDenySet.size === 0) return true; if (await isModelRestrictionExempt(modelId)) return true; + // TODO: Consider removing latest aliases instead of retaining this model-policy exception. + if (isLatestModelAlias(normalizedModelId)) { + // Provider compatibility is enforced at inference through provider routing options. + return !providerAllowSet || providerAllowSet.size > 0; + } if (modelDenySet.has(normalizedModelId)) { return false; } diff --git a/apps/web/src/lib/organizations/effective-model-access.server.test.ts b/apps/web/src/lib/organizations/effective-model-access.server.test.ts index 2550d352df..360d4f5412 100644 --- a/apps/web/src/lib/organizations/effective-model-access.server.test.ts +++ b/apps/web/src/lib/organizations/effective-model-access.server.test.ts @@ -4,6 +4,7 @@ import { evaluateEffectiveModelAccessPolicy, getEffectiveModelDecision, } from './effective-model-access.server'; +import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; function context( overrides: Partial = {} @@ -92,6 +93,49 @@ describe('effective organization model access', () => { ).resolves.toEqual({ allowed: true }); }); + it('excludes latest aliases from model restrictions while enforcing provider routes', async () => { + const policy = evaluateEffectiveModelAccessPolicy( + context({ + organization: { + ...context().organization, + settings: { + model_deny_list: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], + provider_allow_list: ['anthropic'], + }, + }, + defaultPolicies: [{ type: 'model_access', data: { mode: 'all' } }], + }) + ); + + await expect( + getEffectiveModelDecision(policy, CLAUDE_SONNET_LATEST_MODEL_ALIAS, async () => { + throw new Error('latest aliases must not use snapshot provider metadata'); + }) + ).resolves.toEqual({ + allowed: true, + eligibleProviderRoutes: new Set(['anthropic']), + }); + }); + + it('denies latest aliases when the organization allows no providers', async () => { + const policy = evaluateEffectiveModelAccessPolicy( + context({ + organization: { + ...context().organization, + settings: { + model_deny_list: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], + provider_allow_list: [], + }, + }, + defaultPolicies: [{ type: 'model_access', data: { mode: 'all' } }], + }) + ); + + await expect( + getEffectiveModelDecision(policy, CLAUDE_SONNET_LATEST_MODEL_ALIAS) + ).resolves.toEqual({ allowed: false, denialSource: 'organization_provider' }); + }); + it.each(['kilo-auto/balanced', 'kilo-internal/private-model', 'kimi-coding/kimi-for-coding'])( 'keeps %s exempt from effective Enterprise restrictions', async modelId => { diff --git a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts index ed3496b404..992029802f 100644 --- a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts +++ b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts @@ -9,6 +9,7 @@ import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; import { isModelRestrictionExempt } from '@/lib/model-allow.server'; +import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; import { db } from '@/lib/drizzle'; import { getOrganizationGroupPolicyContext, @@ -112,12 +113,15 @@ export async function getEffectiveModelDecision( if (await isModelRestrictionExempt(modelId)) { return { allowed: true }; } - if (policy.organizationModelDenyList.includes(normalizedModelId)) { + // TODO: Consider removing latest aliases instead of retaining this model-policy exception. + const latestAlias = isLatestModelAlias(normalizedModelId); + if (!latestAlias && policy.organizationModelDenyList.includes(normalizedModelId)) { return { allowed: false, denialSource: 'organization_model' }; } - const currentModelProviders = policy.requireModelInCurrentSnapshot - ? await providerLookup(normalizedModelId) - : undefined; + const currentModelProviders = + policy.requireModelInCurrentSnapshot && !latestAlias + ? await providerLookup(normalizedModelId) + : undefined; if (currentModelProviders?.size === 0) { return { allowed: false, denialSource: 'organization_model' }; } @@ -127,6 +131,12 @@ export async function getEffectiveModelDecision( async function decisionWithinOrganizationCeiling(): Promise { if (!organizationRoutes) return { allowed: true }; + if (latestAlias) { + // Aliases have no snapshot endpoints, so pass the ceiling through to provider.only. + return organizationRoutes.size > 0 + ? { allowed: true, eligibleProviderRoutes: organizationRoutes } + : { allowed: false, denialSource: 'organization_provider' }; + } const modelProviders = currentModelProviders ?? (await providerLookup(normalizedModelId)); if (modelProviders.size === 0) { return { allowed: false, denialSource: 'organization_model' }; @@ -148,7 +158,9 @@ export async function getEffectiveModelDecision( if (policy.memberGrant.providerAllowList.length === 0) { return { allowed: false, denialSource: 'no_grant' }; } - const modelProviders = currentModelProviders ?? (await providerLookup(normalizedModelId)); + const modelProviders = latestAlias + ? new Set(policy.memberGrant.providerAllowList) + : (currentModelProviders ?? (await providerLookup(normalizedModelId))); if (modelProviders.size === 0) { return { allowed: false, denialSource: 'group_provider' }; } diff --git a/apps/web/src/routers/organizations/organization-settings-router.test.ts b/apps/web/src/routers/organizations/organization-settings-router.test.ts index 56e7c563c2..b2490caad3 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.test.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.test.ts @@ -53,6 +53,7 @@ jest.mock('@/lib/ai-gateway/experiments/membership', () => ({ import { getEnhancedOpenRouterModels } from '@/lib/ai-gateway/providers/openrouter'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; import { isPublicIdExperimented } from '@/lib/ai-gateway/experiments/membership'; +import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; function makeTestOpenRouterModel(id: string): OpenRouterModel { return { @@ -371,6 +372,27 @@ describe('organizations settings trpc router', () => { expect(result.data.map(model => model.id)).toEqual(['openai/gpt-4o']); }); + it('keeps latest aliases available despite model deny lists and missing snapshot routes', async () => { + const organization = await createTestOrganization( + 'Latest Alias Enterprise', + owner.id, + 0, + { model_deny_list: [CLAUDE_SONNET_LATEST_MODEL_ALIAS] }, + false + ); + await addUserToOrganization(organization.id, member.id, 'member'); + mockedGetEnhancedOpenRouterModels.mockResolvedValue({ + data: [makeOpenRouterModel(CLAUDE_SONNET_LATEST_MODEL_ALIAS)], + } satisfies OpenRouterModelsResponse); + + const caller = await createCallerForUser(member.id); + const result = await caller.organizations.settings.listAvailableModels({ + organizationId: organization.id, + }); + + expect(result.data.map(model => model.id)).toEqual([CLAUDE_SONNET_LATEST_MODEL_ALIAS]); + }); + it('should exclude models in model_deny_list for enterprise orgs', async () => { const openRouterModelsResponse = { data: [ From 701658ea509ca2aac55cc7338c4429e77635cd6d Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 17:09:13 +0200 Subject: [PATCH 6/9] fix(organizations): keep snapshot policies consistent --- .../organizations/[id]/defaults/route.test.ts | 26 +++++++++++ .../api/organizations/[id]/defaults/route.ts | 3 +- .../lib/ai-gateway/latest-model-aliases.ts | 31 +++++++++++++ .../vercel/mapModelIdToVercel.test.ts | 8 ++++ apps/web/src/lib/ai-gateway/providers/xai.ts | 1 + apps/web/src/lib/model-allow.server.test.ts | 12 ++++- apps/web/src/lib/model-allow.server.ts | 13 ++++-- .../effective-model-access.server.test.ts | 6 ++- .../model-access/model-access.server.ts | 23 +++++----- .../lib/slack-bot/model-allow-list.test.ts | 46 +++++++++++++++++++ .../web/src/lib/slack-bot/model-allow-list.ts | 25 ++++++---- .../organization-modes-router.test.ts | 6 +++ 12 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/lib/slack-bot/model-allow-list.test.ts diff --git a/apps/web/src/app/api/organizations/[id]/defaults/route.test.ts b/apps/web/src/app/api/organizations/[id]/defaults/route.test.ts index 0ccdc26909..4639654ca4 100644 --- a/apps/web/src/app/api/organizations/[id]/defaults/route.test.ts +++ b/apps/web/src/app/api/organizations/[id]/defaults/route.test.ts @@ -99,6 +99,32 @@ describe('GET /api/organizations/[id]/defaults', () => { expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled(); }); + test('Enterprise without configured restrictions still requires snapshot membership', async () => { + const user = await insertTestUser(); + const organization = await createOrganization('Enterprise Snapshot Org', user.id); + mockedGetProviderSlugsForModel.mockResolvedValue(new Set()); + mockedGetEnhancedOpenRouterModels.mockResolvedValue({ + data: [makeOpenRouterModel(PRIMARY_DEFAULT_MODEL)], + }); + mockedGetAuthorizedOrgContext.mockResolvedValue({ + success: true, + data: { + user: { ...user, role: 'owner' }, + organization: { + ...organization, + plan: 'enterprise' as const, + settings: {}, + }, + }, + }); + + const response = await GET(new NextRequest('http://localhost:3000'), { + params: Promise.resolve({ id: organization.id }), + }); + + expect(response.status).toBe(409); + }); + 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); diff --git a/apps/web/src/app/api/organizations/[id]/defaults/route.ts b/apps/web/src/app/api/organizations/[id]/defaults/route.ts index eaa036c746..4899b1953c 100644 --- a/apps/web/src/app/api/organizations/[id]/defaults/route.ts +++ b/apps/web/src/app/api/organizations/[id]/defaults/route.ts @@ -110,7 +110,8 @@ export async function GET( if ( policy.memberGrant.mode === 'unrestricted' && policy.organizationModelDenyList.length === 0 && - !policy.organizationProviderCeiling + !policy.organizationProviderCeiling && + !policy.requireModelInCurrentSnapshot ) { // No restrictions - use PRIMARY_DEFAULT_MODEL directly defaultModel = PRIMARY_DEFAULT_MODEL; diff --git a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts index a60a785c75..78526c7697 100644 --- a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts +++ b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts @@ -1,3 +1,17 @@ +import { + CLAUDE_FABLE_CURRENT_MODEL_ID, + CLAUDE_HAIKU_CURRENT_MODEL_ID, + CLAUDE_OPUS_CURRENT_MODEL_ID, + CLAUDE_SONNET_CURRENT_MODEL_ID, +} from '@/lib/ai-gateway/providers/anthropic.constants'; +import { + GEMINI_FLASH_CURRENT_MODEL_ID, + GEMINI_PRO_CURRENT_MODEL_ID, +} from '@/lib/ai-gateway/providers/google'; +import { KIMI_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/moonshotai'; +import { GPT_CURRENT_MODEL_ID, GPT_MINI_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/openai'; +import { GROK_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/xai'; + export const CLAUDE_FABLE_LATEST_MODEL_ALIAS = '~anthropic/claude-fable-latest'; export const CLAUDE_OPUS_LATEST_MODEL_ALIAS = '~anthropic/claude-opus-latest'; export const CLAUDE_SONNET_LATEST_MODEL_ALIAS = '~anthropic/claude-sonnet-latest'; @@ -24,6 +38,23 @@ export const LATEST_MODEL_ALIASES = [ const latestModelAliasSet = new Set(LATEST_MODEL_ALIASES); +const latestModelAliasTargets = new Map([ + [CLAUDE_FABLE_LATEST_MODEL_ALIAS, CLAUDE_FABLE_CURRENT_MODEL_ID], + [CLAUDE_OPUS_LATEST_MODEL_ALIAS, CLAUDE_OPUS_CURRENT_MODEL_ID], + [CLAUDE_SONNET_LATEST_MODEL_ALIAS, CLAUDE_SONNET_CURRENT_MODEL_ID], + [CLAUDE_HAIKU_LATEST_MODEL_ALIAS, CLAUDE_HAIKU_CURRENT_MODEL_ID], + [GPT_LATEST_MODEL_ALIAS, GPT_CURRENT_MODEL_ID], + [GPT_MINI_LATEST_MODEL_ALIAS, GPT_MINI_CURRENT_MODEL_ID], + [KIMI_LATEST_MODEL_ALIAS, KIMI_CURRENT_MODEL_ID], + [GEMINI_PRO_LATEST_MODEL_ALIAS, GEMINI_PRO_CURRENT_MODEL_ID], + [GEMINI_FLASH_LATEST_MODEL_ALIAS, GEMINI_FLASH_CURRENT_MODEL_ID], + [GROK_LATEST_MODEL_ALIAS, GROK_CURRENT_MODEL_ID], +]); + export function isLatestModelAlias(modelId: string): boolean { return latestModelAliasSet.has(modelId.trim().toLowerCase()); } + +export function resolveLatestModelAlias(modelId: string): string | undefined { + return latestModelAliasTargets.get(modelId.trim().toLowerCase()); +} diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts index b29c19fecf..aa8c5a7025 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts @@ -3,6 +3,7 @@ import { CLAUDE_FABLE_CURRENT_VERCEL_MODEL_ID, CLAUDE_HAIKU_CURRENT_VERCEL_MODEL_ID, CLAUDE_OPUS_CURRENT_VERCEL_MODEL_ID, + CLAUDE_SONNET_CURRENT_MODEL_ID, CLAUDE_SONNET_CURRENT_VERCEL_MODEL_ID, } from '@/lib/ai-gateway/providers/anthropic.constants'; import { @@ -28,6 +29,7 @@ import { GROK_LATEST_MODEL_ALIAS, KIMI_LATEST_MODEL_ALIAS, LATEST_MODEL_ALIASES, + resolveLatestModelAlias, } from '@/lib/ai-gateway/latest-model-aliases'; describe('mapModelIdToVercel', () => { @@ -62,6 +64,12 @@ describe('mapModelIdToVercel', () => { ]); }); + it('resolves aliases to their provider-catalog model ids', () => { + expect(resolveLatestModelAlias(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).toBe( + CLAUDE_SONNET_CURRENT_MODEL_ID + ); + }); + it('does not map a latest alias that is missing the leading tilde', () => { expect(mapModelIdToVercel('anthropic/claude-opus-latest')).toBe( 'anthropic/claude-opus-latest' diff --git a/apps/web/src/lib/ai-gateway/providers/xai.ts b/apps/web/src/lib/ai-gateway/providers/xai.ts index e3c983ced0..02c5123307 100644 --- a/apps/web/src/lib/ai-gateway/providers/xai.ts +++ b/apps/web/src/lib/ai-gateway/providers/xai.ts @@ -1,3 +1,4 @@ +export const GROK_CURRENT_MODEL_ID = 'x-ai/grok-4.5'; export const GROK_CURRENT_VERCEL_MODEL_ID = 'xai/grok-4.5'; export function isGrokModel(requestedModel: string) { diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index a32065ac1a..df80976684 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -5,6 +5,7 @@ import { type ProviderLookup, } from '@/lib/model-allow.server'; import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; +import { CLAUDE_SONNET_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/anthropic.constants'; function lookup(map: Record): ProviderLookup { return async modelId => new Set(map[modelId] ?? []); @@ -97,7 +98,15 @@ describe('model access predicates', () => { providerAllowList: ['anthropic'], modelDenyList: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], }, - lookup({}) + lookup({ [CLAUDE_SONNET_CURRENT_MODEL_ID]: ['anthropic'] }) + ); + const withIncompatibleProviders = createAllowPredicateFromRestrictions( + { + requireModelInCurrentSnapshot: true, + providerAllowList: ['openai'], + modelDenyList: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], + }, + lookup({ [CLAUDE_SONNET_CURRENT_MODEL_ID]: ['anthropic'] }) ); const withoutProviders = createAllowPredicateFromRestrictions( { @@ -109,6 +118,7 @@ describe('model access predicates', () => { ); await expect(withProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(true); + await expect(withIncompatibleProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(false); await expect(withoutProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(false); }); diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index 922f78a319..8c1bad6352 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -6,7 +6,7 @@ import { } from '@/lib/ai-gateway/model-utils'; import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; -import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; +import { resolveLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; @@ -47,9 +47,14 @@ export function createAllowPredicateFromProviderAllowList( if (!requireModelInCurrentSnapshot && !providerAllowSet && modelDenySet.size === 0) return true; if (await isModelRestrictionExempt(modelId)) return true; // TODO: Consider removing latest aliases instead of retaining this model-policy exception. - if (isLatestModelAlias(normalizedModelId)) { - // Provider compatibility is enforced at inference through provider routing options. - return !providerAllowSet || providerAllowSet.size > 0; + const latestAliasTarget = resolveLatestModelAlias(normalizedModelId); + if (latestAliasTarget) { + if (!providerAllowSet || providerAllowSet.size === 0) return !providerAllowSet; + const providerSlugs = await providerLookup(latestAliasTarget); + // Provider options remain the fallback when the alias target has no snapshot routes yet. + return ( + providerSlugs.size === 0 || [...providerSlugs].some(slug => providerAllowSet.has(slug)) + ); } if (modelDenySet.has(normalizedModelId)) { return false; diff --git a/apps/web/src/lib/organizations/effective-model-access.server.test.ts b/apps/web/src/lib/organizations/effective-model-access.server.test.ts index 360d4f5412..e3738cabe3 100644 --- a/apps/web/src/lib/organizations/effective-model-access.server.test.ts +++ b/apps/web/src/lib/organizations/effective-model-access.server.test.ts @@ -5,6 +5,7 @@ import { getEffectiveModelDecision, } from './effective-model-access.server'; import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; +import { CLAUDE_SONNET_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/anthropic.constants'; function context( overrides: Partial = {} @@ -108,8 +109,9 @@ describe('effective organization model access', () => { ); await expect( - getEffectiveModelDecision(policy, CLAUDE_SONNET_LATEST_MODEL_ALIAS, async () => { - throw new Error('latest aliases must not use snapshot provider metadata'); + getEffectiveModelDecision(policy, CLAUDE_SONNET_LATEST_MODEL_ALIAS, async modelId => { + expect(modelId).toBe(CLAUDE_SONNET_CURRENT_MODEL_ID); + return new Set(['anthropic']); }) ).resolves.toEqual({ allowed: true, diff --git a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts index 992029802f..a52ca3430c 100644 --- a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts +++ b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts @@ -9,7 +9,7 @@ import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; import { isModelRestrictionExempt } from '@/lib/model-allow.server'; -import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; +import { resolveLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; import { db } from '@/lib/drizzle'; import { getOrganizationGroupPolicyContext, @@ -114,15 +114,15 @@ export async function getEffectiveModelDecision( return { allowed: true }; } // TODO: Consider removing latest aliases instead of retaining this model-policy exception. - const latestAlias = isLatestModelAlias(normalizedModelId); + const latestAliasTarget = resolveLatestModelAlias(normalizedModelId); + const latestAlias = latestAliasTarget !== undefined; if (!latestAlias && policy.organizationModelDenyList.includes(normalizedModelId)) { return { allowed: false, denialSource: 'organization_model' }; } - const currentModelProviders = - policy.requireModelInCurrentSnapshot && !latestAlias - ? await providerLookup(normalizedModelId) - : undefined; - if (currentModelProviders?.size === 0) { + const currentModelProviders = policy.requireModelInCurrentSnapshot + ? await providerLookup(latestAliasTarget ?? normalizedModelId) + : undefined; + if (!latestAlias && currentModelProviders?.size === 0) { return { allowed: false, denialSource: 'organization_model' }; } const organizationRoutes = policy.organizationProviderCeiling @@ -131,7 +131,7 @@ export async function getEffectiveModelDecision( async function decisionWithinOrganizationCeiling(): Promise { if (!organizationRoutes) return { allowed: true }; - if (latestAlias) { + if (latestAlias && currentModelProviders?.size === 0) { // Aliases have no snapshot endpoints, so pass the ceiling through to provider.only. return organizationRoutes.size > 0 ? { allowed: true, eligibleProviderRoutes: organizationRoutes } @@ -158,9 +158,10 @@ export async function getEffectiveModelDecision( if (policy.memberGrant.providerAllowList.length === 0) { return { allowed: false, denialSource: 'no_grant' }; } - const modelProviders = latestAlias - ? new Set(policy.memberGrant.providerAllowList) - : (currentModelProviders ?? (await providerLookup(normalizedModelId))); + const modelProviders = + latestAlias && currentModelProviders?.size === 0 + ? new Set(policy.memberGrant.providerAllowList) + : (currentModelProviders ?? (await providerLookup(latestAliasTarget ?? normalizedModelId))); if (modelProviders.size === 0) { return { allowed: false, denialSource: 'group_provider' }; } diff --git a/apps/web/src/lib/slack-bot/model-allow-list.test.ts b/apps/web/src/lib/slack-bot/model-allow-list.test.ts new file mode 100644 index 0000000000..ad5ac1ac06 --- /dev/null +++ b/apps/web/src/lib/slack-bot/model-allow-list.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { getDefaultAllowedModel } from './model-allow-list'; +import { getOrganizationById } from '@/lib/organizations/organizations'; +import { + getEffectiveModelDecision, + resolveOrganizationDefaultModelPolicy, +} from '@/lib/organizations/effective-model-access.server'; +import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; + +jest.mock('@/lib/organizations/organizations'); +jest.mock('@/lib/organizations/effective-model-access.server'); +jest.mock('@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'); + +const mockedGetOrganizationById = jest.mocked(getOrganizationById); +const mockedGetEffectiveModelDecision = jest.mocked(getEffectiveModelDecision); +const mockedResolveOrganizationDefaultModelPolicy = jest.mocked( + resolveOrganizationDefaultModelPolicy +); +const mockedGetModelIdToProviderSlugsIndex = jest.mocked(getModelIdToProviderSlugsIndex); + +describe('getDefaultAllowedModel', () => { + beforeEach(() => { + mockedGetOrganizationById.mockReset(); + mockedGetEffectiveModelDecision.mockReset(); + mockedResolveOrganizationDefaultModelPolicy.mockReset(); + mockedGetModelIdToProviderSlugsIndex.mockReset(); + }); + + test('falls back to an allowed snapshot model for Enterprise', async () => { + mockedGetOrganizationById.mockResolvedValue({ settings: {} } as never); + mockedResolveOrganizationDefaultModelPolicy.mockResolvedValue({ + requireModelInCurrentSnapshot: true, + organizationModelDenyList: [], + memberGrant: { mode: 'unrestricted' }, + policyRevision: 0, + }); + mockedGetEffectiveModelDecision.mockImplementation(async (_policy, modelId) => ({ + allowed: modelId === 'snapshot/fallback', + })); + mockedGetModelIdToProviderSlugsIndex.mockResolvedValue( + new Map([['snapshot/fallback', new Set(['provider'])]]) + ); + + await expect(getDefaultAllowedModel('organization-id')).resolves.toBe('snapshot/fallback'); + }); +}); diff --git a/apps/web/src/lib/slack-bot/model-allow-list.ts b/apps/web/src/lib/slack-bot/model-allow-list.ts index bd3117d357..7c9ac6ebb7 100644 --- a/apps/web/src/lib/slack-bot/model-allow-list.ts +++ b/apps/web/src/lib/slack-bot/model-allow-list.ts @@ -4,6 +4,7 @@ import { getEffectiveModelDecision, resolveOrganizationDefaultModelPolicy, } from '@/lib/organizations/effective-model-access.server'; +import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; /** * Get a default model that is allowed for an organization. @@ -19,16 +20,17 @@ export async function getDefaultAllowedModel( } // Resolve the organization's default policy once. When it imposes no - // restriction (non-Enterprise, or an unrestricted grant with no deny list and - // no provider ceiling), return `globalDefault` exactly as the pre-policy code - // did. The organization's own `default_model` is only consulted on the - // restricted path below, after `isAllowed` accepts it, because it may hold a - // non-routable virtual id such as `organization-auto`. + // restriction (non-Enterprise with an unrestricted grant), return + // `globalDefault` exactly as the pre-policy code did. The organization's own + // `default_model` is only consulted on the restricted path below, after + // `isAllowed` accepts it, because it may hold a non-routable virtual id such + // as `organization-auto`. const policy = await resolveOrganizationDefaultModelPolicy({ organizationId }); const isUnrestricted = policy.memberGrant.mode === 'unrestricted' && policy.organizationModelDenyList.length === 0 && - !policy.organizationProviderCeiling; + !policy.organizationProviderCeiling && + !policy.requireModelInCurrentSnapshot; if (isUnrestricted) { return globalDefault; } @@ -53,7 +55,12 @@ export async function getDefaultAllowedModel( } } - // All models were blocked; fall back to global default - console.warn('[SlackBot] No allowed model found; org policy blocks all preferred models'); - return globalDefault; + const providerIndex = await getModelIdToProviderSlugsIndex(); + for (const modelId of providerIndex.keys()) { + if (await isAllowed(modelId)) { + return modelId; + } + } + + throw new Error('No allowed default model is available for this organization'); } diff --git a/apps/web/src/routers/organizations/organization-modes-router.test.ts b/apps/web/src/routers/organizations/organization-modes-router.test.ts index 1ea675d421..cc4a8a3d95 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.test.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.test.ts @@ -13,6 +13,12 @@ jest.mock('@/lib/posthog-feature-flags', () => ({ isReleaseToggleEnabled: jest.fn(async () => true), })); +jest.mock('@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server', () => ({ + getProviderSlugsForModel: jest.fn(async (modelId: string) => + modelId === 'openai/gpt-4o' ? new Set(['openai']) : new Set() + ), +})); + const mockedIsReleaseToggleEnabled = jest.mocked( jest.requireMock('@/lib/posthog-feature-flags').isReleaseToggleEnabled ); From 9957bea33ee20820cfdc56e68cbbfdfa5f737fb6 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 17:28:09 +0200 Subject: [PATCH 7/9] fix(organizations): handle model policy edge cases --- .../lib/ai-gateway/latest-model-aliases.ts | 31 ------------------- .../vercel/mapModelIdToVercel.test.ts | 8 ----- apps/web/src/lib/ai-gateway/providers/xai.ts | 1 - .../lib/integrations/discord-service.test.ts | 29 +++++++++++++++++ .../src/lib/integrations/discord-service.ts | 6 ++++ .../src/lib/integrations/linear-service.ts | 19 ++++++++---- .../web/src/lib/integrations/slack-service.ts | 28 ++++++++++------- apps/web/src/lib/model-allow.server.test.ts | 12 +------ apps/web/src/lib/model-allow.server.ts | 13 +++----- .../effective-model-access.server.test.ts | 6 ++-- .../model-access/model-access.server.ts | 23 +++++++------- .../lib/slack-bot/model-allow-list.test.ts | 14 +++++++++ .../web/src/lib/slack-bot/model-allow-list.ts | 4 +-- 13 files changed, 99 insertions(+), 95 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts index 78526c7697..a60a785c75 100644 --- a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts +++ b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts @@ -1,17 +1,3 @@ -import { - CLAUDE_FABLE_CURRENT_MODEL_ID, - CLAUDE_HAIKU_CURRENT_MODEL_ID, - CLAUDE_OPUS_CURRENT_MODEL_ID, - CLAUDE_SONNET_CURRENT_MODEL_ID, -} from '@/lib/ai-gateway/providers/anthropic.constants'; -import { - GEMINI_FLASH_CURRENT_MODEL_ID, - GEMINI_PRO_CURRENT_MODEL_ID, -} from '@/lib/ai-gateway/providers/google'; -import { KIMI_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/moonshotai'; -import { GPT_CURRENT_MODEL_ID, GPT_MINI_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/openai'; -import { GROK_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/xai'; - export const CLAUDE_FABLE_LATEST_MODEL_ALIAS = '~anthropic/claude-fable-latest'; export const CLAUDE_OPUS_LATEST_MODEL_ALIAS = '~anthropic/claude-opus-latest'; export const CLAUDE_SONNET_LATEST_MODEL_ALIAS = '~anthropic/claude-sonnet-latest'; @@ -38,23 +24,6 @@ export const LATEST_MODEL_ALIASES = [ const latestModelAliasSet = new Set(LATEST_MODEL_ALIASES); -const latestModelAliasTargets = new Map([ - [CLAUDE_FABLE_LATEST_MODEL_ALIAS, CLAUDE_FABLE_CURRENT_MODEL_ID], - [CLAUDE_OPUS_LATEST_MODEL_ALIAS, CLAUDE_OPUS_CURRENT_MODEL_ID], - [CLAUDE_SONNET_LATEST_MODEL_ALIAS, CLAUDE_SONNET_CURRENT_MODEL_ID], - [CLAUDE_HAIKU_LATEST_MODEL_ALIAS, CLAUDE_HAIKU_CURRENT_MODEL_ID], - [GPT_LATEST_MODEL_ALIAS, GPT_CURRENT_MODEL_ID], - [GPT_MINI_LATEST_MODEL_ALIAS, GPT_MINI_CURRENT_MODEL_ID], - [KIMI_LATEST_MODEL_ALIAS, KIMI_CURRENT_MODEL_ID], - [GEMINI_PRO_LATEST_MODEL_ALIAS, GEMINI_PRO_CURRENT_MODEL_ID], - [GEMINI_FLASH_LATEST_MODEL_ALIAS, GEMINI_FLASH_CURRENT_MODEL_ID], - [GROK_LATEST_MODEL_ALIAS, GROK_CURRENT_MODEL_ID], -]); - export function isLatestModelAlias(modelId: string): boolean { return latestModelAliasSet.has(modelId.trim().toLowerCase()); } - -export function resolveLatestModelAlias(modelId: string): string | undefined { - return latestModelAliasTargets.get(modelId.trim().toLowerCase()); -} diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts index aa8c5a7025..b29c19fecf 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts @@ -3,7 +3,6 @@ import { CLAUDE_FABLE_CURRENT_VERCEL_MODEL_ID, CLAUDE_HAIKU_CURRENT_VERCEL_MODEL_ID, CLAUDE_OPUS_CURRENT_VERCEL_MODEL_ID, - CLAUDE_SONNET_CURRENT_MODEL_ID, CLAUDE_SONNET_CURRENT_VERCEL_MODEL_ID, } from '@/lib/ai-gateway/providers/anthropic.constants'; import { @@ -29,7 +28,6 @@ import { GROK_LATEST_MODEL_ALIAS, KIMI_LATEST_MODEL_ALIAS, LATEST_MODEL_ALIASES, - resolveLatestModelAlias, } from '@/lib/ai-gateway/latest-model-aliases'; describe('mapModelIdToVercel', () => { @@ -64,12 +62,6 @@ describe('mapModelIdToVercel', () => { ]); }); - it('resolves aliases to their provider-catalog model ids', () => { - expect(resolveLatestModelAlias(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).toBe( - CLAUDE_SONNET_CURRENT_MODEL_ID - ); - }); - it('does not map a latest alias that is missing the leading tilde', () => { expect(mapModelIdToVercel('anthropic/claude-opus-latest')).toBe( 'anthropic/claude-opus-latest' diff --git a/apps/web/src/lib/ai-gateway/providers/xai.ts b/apps/web/src/lib/ai-gateway/providers/xai.ts index 02c5123307..e3c983ced0 100644 --- a/apps/web/src/lib/ai-gateway/providers/xai.ts +++ b/apps/web/src/lib/ai-gateway/providers/xai.ts @@ -1,4 +1,3 @@ -export const GROK_CURRENT_MODEL_ID = 'x-ai/grok-4.5'; export const GROK_CURRENT_VERCEL_MODEL_ID = 'xai/grok-4.5'; export function isGrokModel(requestedModel: string) { diff --git a/apps/web/src/lib/integrations/discord-service.test.ts b/apps/web/src/lib/integrations/discord-service.test.ts index 28524516db..a570381032 100644 --- a/apps/web/src/lib/integrations/discord-service.test.ts +++ b/apps/web/src/lib/integrations/discord-service.test.ts @@ -58,6 +58,10 @@ import { upsertDiscordInstallation, } from './discord-service'; +const mockedGetDefaultAllowedModel = jest.mocked( + jest.requireMock('@/lib/slack-bot/model-allow-list').getDefaultAllowedModel +); + const owner = { type: 'user', id: 'user-1' } satisfies Owner; function buildDiscordIntegration(overrides: Record = {}) { @@ -147,6 +151,7 @@ describe('discord-service persisted guild ID validation', () => { mockUpdateReturning.mockReset(); mockInsertValues.mockReset(); mockInsertReturning.mockReset(); + mockedGetDefaultAllowedModel.mockResolvedValue('gpt-test'); }); it('does not test a connection when the stored guild ID is malformed', async () => { @@ -177,6 +182,30 @@ describe('discord-service persisted guild ID validation', () => { expect(mockInsertValues).not.toHaveBeenCalled(); }); + it('returns an actionable error when organization policy allows no model', async () => { + mockLimit.mockResolvedValue([]); + mockedGetDefaultAllowedModel.mockResolvedValue(null); + + await expect( + upsertDiscordInstallation( + { type: 'org', id: 'organization-id' }, + { + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-token', + scope: 'bot guilds', + guild: { id: '123456789012345678', name: 'Test Guild', icon: null }, + } + ) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'No model is available under the organization provider policy.', + }); + + expect(mockInsertValues).not.toHaveBeenCalled(); + }); + it('tests valid stored guild IDs through the fixed Discord API origin', async () => { mockLimit.mockResolvedValue([buildDiscordIntegration()]); const fetchSpy = jest diff --git a/apps/web/src/lib/integrations/discord-service.ts b/apps/web/src/lib/integrations/discord-service.ts index 03959f8266..5ae2e23d5e 100644 --- a/apps/web/src/lib/integrations/discord-service.ts +++ b/apps/web/src/lib/integrations/discord-service.ts @@ -210,6 +210,12 @@ export async function upsertDiscordInstallation( owner.type === 'org' ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) : DEFAULT_BOT_MODEL; + if (!defaultModel) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No model is available under the organization provider policy.', + }); + } const metadata = { guild_icon: oauthResponse.guild.icon, diff --git a/apps/web/src/lib/integrations/linear-service.ts b/apps/web/src/lib/integrations/linear-service.ts index 25ad5af5b8..150b80ad0b 100644 --- a/apps/web/src/lib/integrations/linear-service.ts +++ b/apps/web/src/lib/integrations/linear-service.ts @@ -366,11 +366,6 @@ export async function upsertLinearInstallation( await options.deleteChatSdkIdentityCache?.(previousOrganizationId); } - const defaultModel = - owner.type === 'org' - ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) - : DEFAULT_BOT_MODEL; - const existingMetadata = existing?.metadata && typeof existing.metadata === 'object' ? existing.metadata : {}; @@ -379,11 +374,23 @@ export async function upsertLinearInstallation( ? existingMetadata.model_slug : null; + const defaultModel = + existingModelSlug ?? + (owner.type === 'org' + ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) + : DEFAULT_BOT_MODEL); + if (!defaultModel) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No model is available under the organization provider policy.', + }); + } + const metadata = { ...existingMetadata, bot_enabled: true, bot_user_id: botUserId, - model_slug: existingModelSlug ?? defaultModel, + model_slug: defaultModel, }; if (existing) { diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts index 9e317bb38e..df97898c67 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -211,24 +211,30 @@ export async function upsertSlackInstallation({ throw new SlackWorkspaceAlreadyConnectedError(teamName); } - // For org integrations, get a model that respects org access policy. - // For user integrations, use the shared bot default model. + const existingModelSlug = + existing?.metadata && + typeof existing.metadata === 'object' && + 'model_slug' in existing.metadata && + typeof existing.metadata.model_slug === 'string' + ? existing.metadata.model_slug + : null; const defaultModel = - owner.type === 'org' + existingModelSlug ?? + (owner.type === 'org' ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) - : DEFAULT_BOT_MODEL; + : DEFAULT_BOT_MODEL); + if (!defaultModel) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No model is available under the organization provider policy.', + }); + } const metadata = { ...(existing?.metadata && typeof existing.metadata === 'object' ? existing.metadata : {}), access_token: installation.botToken, bot_user_id: installation.botUserId, - model_slug: - existing?.metadata && - typeof existing.metadata === 'object' && - 'model_slug' in existing.metadata && - typeof existing.metadata.model_slug === 'string' - ? existing.metadata.model_slug - : defaultModel, + model_slug: defaultModel, }; if (existing) { diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index df80976684..a32065ac1a 100644 --- a/apps/web/src/lib/model-allow.server.test.ts +++ b/apps/web/src/lib/model-allow.server.test.ts @@ -5,7 +5,6 @@ import { type ProviderLookup, } from '@/lib/model-allow.server'; import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; -import { CLAUDE_SONNET_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/anthropic.constants'; function lookup(map: Record): ProviderLookup { return async modelId => new Set(map[modelId] ?? []); @@ -98,15 +97,7 @@ describe('model access predicates', () => { providerAllowList: ['anthropic'], modelDenyList: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], }, - lookup({ [CLAUDE_SONNET_CURRENT_MODEL_ID]: ['anthropic'] }) - ); - const withIncompatibleProviders = createAllowPredicateFromRestrictions( - { - requireModelInCurrentSnapshot: true, - providerAllowList: ['openai'], - modelDenyList: [CLAUDE_SONNET_LATEST_MODEL_ALIAS], - }, - lookup({ [CLAUDE_SONNET_CURRENT_MODEL_ID]: ['anthropic'] }) + lookup({}) ); const withoutProviders = createAllowPredicateFromRestrictions( { @@ -118,7 +109,6 @@ describe('model access predicates', () => { ); await expect(withProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(true); - await expect(withIncompatibleProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(false); await expect(withoutProviders(CLAUDE_SONNET_LATEST_MODEL_ALIAS)).resolves.toBe(false); }); diff --git a/apps/web/src/lib/model-allow.server.ts b/apps/web/src/lib/model-allow.server.ts index 8c1bad6352..922f78a319 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -6,7 +6,7 @@ import { } from '@/lib/ai-gateway/model-utils'; import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; -import { resolveLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; +import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; export type ProviderAwareAllowPredicate = (modelId: string) => Promise; @@ -47,14 +47,9 @@ export function createAllowPredicateFromProviderAllowList( if (!requireModelInCurrentSnapshot && !providerAllowSet && modelDenySet.size === 0) return true; if (await isModelRestrictionExempt(modelId)) return true; // TODO: Consider removing latest aliases instead of retaining this model-policy exception. - const latestAliasTarget = resolveLatestModelAlias(normalizedModelId); - if (latestAliasTarget) { - if (!providerAllowSet || providerAllowSet.size === 0) return !providerAllowSet; - const providerSlugs = await providerLookup(latestAliasTarget); - // Provider options remain the fallback when the alias target has no snapshot routes yet. - return ( - providerSlugs.size === 0 || [...providerSlugs].some(slug => providerAllowSet.has(slug)) - ); + if (isLatestModelAlias(normalizedModelId)) { + // Provider compatibility is enforced at inference through provider routing options. + return !providerAllowSet || providerAllowSet.size > 0; } if (modelDenySet.has(normalizedModelId)) { return false; diff --git a/apps/web/src/lib/organizations/effective-model-access.server.test.ts b/apps/web/src/lib/organizations/effective-model-access.server.test.ts index e3738cabe3..360d4f5412 100644 --- a/apps/web/src/lib/organizations/effective-model-access.server.test.ts +++ b/apps/web/src/lib/organizations/effective-model-access.server.test.ts @@ -5,7 +5,6 @@ import { getEffectiveModelDecision, } from './effective-model-access.server'; import { CLAUDE_SONNET_LATEST_MODEL_ALIAS } from '@/lib/ai-gateway/latest-model-aliases'; -import { CLAUDE_SONNET_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/anthropic.constants'; function context( overrides: Partial = {} @@ -109,9 +108,8 @@ describe('effective organization model access', () => { ); await expect( - getEffectiveModelDecision(policy, CLAUDE_SONNET_LATEST_MODEL_ALIAS, async modelId => { - expect(modelId).toBe(CLAUDE_SONNET_CURRENT_MODEL_ID); - return new Set(['anthropic']); + getEffectiveModelDecision(policy, CLAUDE_SONNET_LATEST_MODEL_ALIAS, async () => { + throw new Error('latest aliases must not use snapshot provider metadata'); }) ).resolves.toEqual({ allowed: true, diff --git a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts index a52ca3430c..992029802f 100644 --- a/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts +++ b/apps/web/src/lib/organizations/group-policies/model-access/model-access.server.ts @@ -9,7 +9,7 @@ import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import { getProviderSlugsForModel } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; import { isModelRestrictionExempt } from '@/lib/model-allow.server'; -import { resolveLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; +import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; import { db } from '@/lib/drizzle'; import { getOrganizationGroupPolicyContext, @@ -114,15 +114,15 @@ export async function getEffectiveModelDecision( return { allowed: true }; } // TODO: Consider removing latest aliases instead of retaining this model-policy exception. - const latestAliasTarget = resolveLatestModelAlias(normalizedModelId); - const latestAlias = latestAliasTarget !== undefined; + const latestAlias = isLatestModelAlias(normalizedModelId); if (!latestAlias && policy.organizationModelDenyList.includes(normalizedModelId)) { return { allowed: false, denialSource: 'organization_model' }; } - const currentModelProviders = policy.requireModelInCurrentSnapshot - ? await providerLookup(latestAliasTarget ?? normalizedModelId) - : undefined; - if (!latestAlias && currentModelProviders?.size === 0) { + const currentModelProviders = + policy.requireModelInCurrentSnapshot && !latestAlias + ? await providerLookup(normalizedModelId) + : undefined; + if (currentModelProviders?.size === 0) { return { allowed: false, denialSource: 'organization_model' }; } const organizationRoutes = policy.organizationProviderCeiling @@ -131,7 +131,7 @@ export async function getEffectiveModelDecision( async function decisionWithinOrganizationCeiling(): Promise { if (!organizationRoutes) return { allowed: true }; - if (latestAlias && currentModelProviders?.size === 0) { + if (latestAlias) { // Aliases have no snapshot endpoints, so pass the ceiling through to provider.only. return organizationRoutes.size > 0 ? { allowed: true, eligibleProviderRoutes: organizationRoutes } @@ -158,10 +158,9 @@ export async function getEffectiveModelDecision( if (policy.memberGrant.providerAllowList.length === 0) { return { allowed: false, denialSource: 'no_grant' }; } - const modelProviders = - latestAlias && currentModelProviders?.size === 0 - ? new Set(policy.memberGrant.providerAllowList) - : (currentModelProviders ?? (await providerLookup(latestAliasTarget ?? normalizedModelId))); + const modelProviders = latestAlias + ? new Set(policy.memberGrant.providerAllowList) + : (currentModelProviders ?? (await providerLookup(normalizedModelId))); if (modelProviders.size === 0) { return { allowed: false, denialSource: 'group_provider' }; } diff --git a/apps/web/src/lib/slack-bot/model-allow-list.test.ts b/apps/web/src/lib/slack-bot/model-allow-list.test.ts index ad5ac1ac06..76b5ea1b99 100644 --- a/apps/web/src/lib/slack-bot/model-allow-list.test.ts +++ b/apps/web/src/lib/slack-bot/model-allow-list.test.ts @@ -43,4 +43,18 @@ describe('getDefaultAllowedModel', () => { await expect(getDefaultAllowedModel('organization-id')).resolves.toBe('snapshot/fallback'); }); + + test('returns null when Enterprise has no allowed snapshot model', async () => { + mockedGetOrganizationById.mockResolvedValue({ settings: {} } as never); + mockedResolveOrganizationDefaultModelPolicy.mockResolvedValue({ + requireModelInCurrentSnapshot: true, + organizationModelDenyList: [], + memberGrant: { mode: 'unrestricted' }, + policyRevision: 0, + }); + mockedGetEffectiveModelDecision.mockResolvedValue({ allowed: false }); + mockedGetModelIdToProviderSlugsIndex.mockResolvedValue(new Map()); + + await expect(getDefaultAllowedModel('organization-id')).resolves.toBeNull(); + }); }); diff --git a/apps/web/src/lib/slack-bot/model-allow-list.ts b/apps/web/src/lib/slack-bot/model-allow-list.ts index 7c9ac6ebb7..2b815c57bc 100644 --- a/apps/web/src/lib/slack-bot/model-allow-list.ts +++ b/apps/web/src/lib/slack-bot/model-allow-list.ts @@ -13,7 +13,7 @@ import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openr export async function getDefaultAllowedModel( organizationId: string, globalDefault = PRIMARY_DEFAULT_MODEL -): Promise { +): Promise { const organization = await getOrganizationById(organizationId); if (!organization) { return globalDefault; @@ -62,5 +62,5 @@ export async function getDefaultAllowedModel( } } - throw new Error('No allowed default model is available for this organization'); + return null; } From 2a6a5499e5b88c785a9a49fce3f3eca83c4f427b Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 20:01:48 +0200 Subject: [PATCH 8/9] refactor(organizations): keep model policy change scoped --- .../lib/integrations/discord-service.test.ts | 29 --------- .../src/lib/integrations/discord-service.ts | 6 -- .../src/lib/integrations/linear-service.ts | 19 ++---- .../web/src/lib/integrations/slack-service.ts | 28 ++++----- .../lib/slack-bot/model-allow-list.test.ts | 60 ------------------- .../web/src/lib/slack-bot/model-allow-list.ts | 27 ++++----- 6 files changed, 27 insertions(+), 142 deletions(-) delete mode 100644 apps/web/src/lib/slack-bot/model-allow-list.test.ts diff --git a/apps/web/src/lib/integrations/discord-service.test.ts b/apps/web/src/lib/integrations/discord-service.test.ts index a570381032..28524516db 100644 --- a/apps/web/src/lib/integrations/discord-service.test.ts +++ b/apps/web/src/lib/integrations/discord-service.test.ts @@ -58,10 +58,6 @@ import { upsertDiscordInstallation, } from './discord-service'; -const mockedGetDefaultAllowedModel = jest.mocked( - jest.requireMock('@/lib/slack-bot/model-allow-list').getDefaultAllowedModel -); - const owner = { type: 'user', id: 'user-1' } satisfies Owner; function buildDiscordIntegration(overrides: Record = {}) { @@ -151,7 +147,6 @@ describe('discord-service persisted guild ID validation', () => { mockUpdateReturning.mockReset(); mockInsertValues.mockReset(); mockInsertReturning.mockReset(); - mockedGetDefaultAllowedModel.mockResolvedValue('gpt-test'); }); it('does not test a connection when the stored guild ID is malformed', async () => { @@ -182,30 +177,6 @@ describe('discord-service persisted guild ID validation', () => { expect(mockInsertValues).not.toHaveBeenCalled(); }); - it('returns an actionable error when organization policy allows no model', async () => { - mockLimit.mockResolvedValue([]); - mockedGetDefaultAllowedModel.mockResolvedValue(null); - - await expect( - upsertDiscordInstallation( - { type: 'org', id: 'organization-id' }, - { - access_token: 'access-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'refresh-token', - scope: 'bot guilds', - guild: { id: '123456789012345678', name: 'Test Guild', icon: null }, - } - ) - ).rejects.toMatchObject({ - code: 'BAD_REQUEST', - message: 'No model is available under the organization provider policy.', - }); - - expect(mockInsertValues).not.toHaveBeenCalled(); - }); - it('tests valid stored guild IDs through the fixed Discord API origin', async () => { mockLimit.mockResolvedValue([buildDiscordIntegration()]); const fetchSpy = jest diff --git a/apps/web/src/lib/integrations/discord-service.ts b/apps/web/src/lib/integrations/discord-service.ts index 5ae2e23d5e..03959f8266 100644 --- a/apps/web/src/lib/integrations/discord-service.ts +++ b/apps/web/src/lib/integrations/discord-service.ts @@ -210,12 +210,6 @@ export async function upsertDiscordInstallation( owner.type === 'org' ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) : DEFAULT_BOT_MODEL; - if (!defaultModel) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No model is available under the organization provider policy.', - }); - } const metadata = { guild_icon: oauthResponse.guild.icon, diff --git a/apps/web/src/lib/integrations/linear-service.ts b/apps/web/src/lib/integrations/linear-service.ts index 150b80ad0b..25ad5af5b8 100644 --- a/apps/web/src/lib/integrations/linear-service.ts +++ b/apps/web/src/lib/integrations/linear-service.ts @@ -366,6 +366,11 @@ export async function upsertLinearInstallation( await options.deleteChatSdkIdentityCache?.(previousOrganizationId); } + const defaultModel = + owner.type === 'org' + ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) + : DEFAULT_BOT_MODEL; + const existingMetadata = existing?.metadata && typeof existing.metadata === 'object' ? existing.metadata : {}; @@ -374,23 +379,11 @@ export async function upsertLinearInstallation( ? existingMetadata.model_slug : null; - const defaultModel = - existingModelSlug ?? - (owner.type === 'org' - ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) - : DEFAULT_BOT_MODEL); - if (!defaultModel) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No model is available under the organization provider policy.', - }); - } - const metadata = { ...existingMetadata, bot_enabled: true, bot_user_id: botUserId, - model_slug: defaultModel, + model_slug: existingModelSlug ?? defaultModel, }; if (existing) { diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts index df97898c67..9e317bb38e 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -211,30 +211,24 @@ export async function upsertSlackInstallation({ throw new SlackWorkspaceAlreadyConnectedError(teamName); } - const existingModelSlug = - existing?.metadata && - typeof existing.metadata === 'object' && - 'model_slug' in existing.metadata && - typeof existing.metadata.model_slug === 'string' - ? existing.metadata.model_slug - : null; + // For org integrations, get a model that respects org access policy. + // For user integrations, use the shared bot default model. const defaultModel = - existingModelSlug ?? - (owner.type === 'org' + owner.type === 'org' ? await getDefaultAllowedModel(owner.id, DEFAULT_BOT_MODEL) - : DEFAULT_BOT_MODEL); - if (!defaultModel) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'No model is available under the organization provider policy.', - }); - } + : DEFAULT_BOT_MODEL; const metadata = { ...(existing?.metadata && typeof existing.metadata === 'object' ? existing.metadata : {}), access_token: installation.botToken, bot_user_id: installation.botUserId, - model_slug: defaultModel, + model_slug: + existing?.metadata && + typeof existing.metadata === 'object' && + 'model_slug' in existing.metadata && + typeof existing.metadata.model_slug === 'string' + ? existing.metadata.model_slug + : defaultModel, }; if (existing) { diff --git a/apps/web/src/lib/slack-bot/model-allow-list.test.ts b/apps/web/src/lib/slack-bot/model-allow-list.test.ts deleted file mode 100644 index 76b5ea1b99..0000000000 --- a/apps/web/src/lib/slack-bot/model-allow-list.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { beforeEach, describe, expect, test } from '@jest/globals'; -import { getDefaultAllowedModel } from './model-allow-list'; -import { getOrganizationById } from '@/lib/organizations/organizations'; -import { - getEffectiveModelDecision, - resolveOrganizationDefaultModelPolicy, -} from '@/lib/organizations/effective-model-access.server'; -import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; - -jest.mock('@/lib/organizations/organizations'); -jest.mock('@/lib/organizations/effective-model-access.server'); -jest.mock('@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'); - -const mockedGetOrganizationById = jest.mocked(getOrganizationById); -const mockedGetEffectiveModelDecision = jest.mocked(getEffectiveModelDecision); -const mockedResolveOrganizationDefaultModelPolicy = jest.mocked( - resolveOrganizationDefaultModelPolicy -); -const mockedGetModelIdToProviderSlugsIndex = jest.mocked(getModelIdToProviderSlugsIndex); - -describe('getDefaultAllowedModel', () => { - beforeEach(() => { - mockedGetOrganizationById.mockReset(); - mockedGetEffectiveModelDecision.mockReset(); - mockedResolveOrganizationDefaultModelPolicy.mockReset(); - mockedGetModelIdToProviderSlugsIndex.mockReset(); - }); - - test('falls back to an allowed snapshot model for Enterprise', async () => { - mockedGetOrganizationById.mockResolvedValue({ settings: {} } as never); - mockedResolveOrganizationDefaultModelPolicy.mockResolvedValue({ - requireModelInCurrentSnapshot: true, - organizationModelDenyList: [], - memberGrant: { mode: 'unrestricted' }, - policyRevision: 0, - }); - mockedGetEffectiveModelDecision.mockImplementation(async (_policy, modelId) => ({ - allowed: modelId === 'snapshot/fallback', - })); - mockedGetModelIdToProviderSlugsIndex.mockResolvedValue( - new Map([['snapshot/fallback', new Set(['provider'])]]) - ); - - await expect(getDefaultAllowedModel('organization-id')).resolves.toBe('snapshot/fallback'); - }); - - test('returns null when Enterprise has no allowed snapshot model', async () => { - mockedGetOrganizationById.mockResolvedValue({ settings: {} } as never); - mockedResolveOrganizationDefaultModelPolicy.mockResolvedValue({ - requireModelInCurrentSnapshot: true, - organizationModelDenyList: [], - memberGrant: { mode: 'unrestricted' }, - policyRevision: 0, - }); - mockedGetEffectiveModelDecision.mockResolvedValue({ allowed: false }); - mockedGetModelIdToProviderSlugsIndex.mockResolvedValue(new Map()); - - await expect(getDefaultAllowedModel('organization-id')).resolves.toBeNull(); - }); -}); diff --git a/apps/web/src/lib/slack-bot/model-allow-list.ts b/apps/web/src/lib/slack-bot/model-allow-list.ts index 2b815c57bc..bd3117d357 100644 --- a/apps/web/src/lib/slack-bot/model-allow-list.ts +++ b/apps/web/src/lib/slack-bot/model-allow-list.ts @@ -4,7 +4,6 @@ import { getEffectiveModelDecision, resolveOrganizationDefaultModelPolicy, } from '@/lib/organizations/effective-model-access.server'; -import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; /** * Get a default model that is allowed for an organization. @@ -13,24 +12,23 @@ import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openr export async function getDefaultAllowedModel( organizationId: string, globalDefault = PRIMARY_DEFAULT_MODEL -): Promise { +): Promise { const organization = await getOrganizationById(organizationId); if (!organization) { return globalDefault; } // Resolve the organization's default policy once. When it imposes no - // restriction (non-Enterprise with an unrestricted grant), return - // `globalDefault` exactly as the pre-policy code did. The organization's own - // `default_model` is only consulted on the restricted path below, after - // `isAllowed` accepts it, because it may hold a non-routable virtual id such - // as `organization-auto`. + // restriction (non-Enterprise, or an unrestricted grant with no deny list and + // no provider ceiling), return `globalDefault` exactly as the pre-policy code + // did. The organization's own `default_model` is only consulted on the + // restricted path below, after `isAllowed` accepts it, because it may hold a + // non-routable virtual id such as `organization-auto`. const policy = await resolveOrganizationDefaultModelPolicy({ organizationId }); const isUnrestricted = policy.memberGrant.mode === 'unrestricted' && policy.organizationModelDenyList.length === 0 && - !policy.organizationProviderCeiling && - !policy.requireModelInCurrentSnapshot; + !policy.organizationProviderCeiling; if (isUnrestricted) { return globalDefault; } @@ -55,12 +53,7 @@ export async function getDefaultAllowedModel( } } - const providerIndex = await getModelIdToProviderSlugsIndex(); - for (const modelId of providerIndex.keys()) { - if (await isAllowed(modelId)) { - return modelId; - } - } - - return null; + // All models were blocked; fall back to global default + console.warn('[SlackBot] No allowed model found; org policy blocks all preferred models'); + return globalDefault; } From 267a9aa664e49068fd13c940e949e86a681567df Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 20:07:25 +0200 Subject: [PATCH 9/9] fix(ai-gateway): correct DeepSeek latest alias --- apps/web/src/lib/ai-gateway/latest-model-aliases.ts | 4 +++- .../providers/vercel/mapModelIdToVercel.test.ts | 8 +++++--- .../lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts index a60a785c75..f4826028ef 100644 --- a/apps/web/src/lib/ai-gateway/latest-model-aliases.ts +++ b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts @@ -8,6 +8,7 @@ export const KIMI_LATEST_MODEL_ALIAS = '~moonshotai/kimi-latest'; export const GEMINI_PRO_LATEST_MODEL_ALIAS = '~google/gemini-pro-latest'; export const GEMINI_FLASH_LATEST_MODEL_ALIAS = '~google/gemini-flash-latest'; export const GROK_LATEST_MODEL_ALIAS = '~x-ai/grok-latest'; +export const DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS = '~deepseek/deepseek-v4-flash-latest'; export const LATEST_MODEL_ALIASES = [ CLAUDE_FABLE_LATEST_MODEL_ALIAS, @@ -20,10 +21,11 @@ export const LATEST_MODEL_ALIASES = [ GEMINI_PRO_LATEST_MODEL_ALIAS, GEMINI_FLASH_LATEST_MODEL_ALIAS, GROK_LATEST_MODEL_ALIAS, + DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS, ] as const; const latestModelAliasSet = new Set(LATEST_MODEL_ALIASES); export function isLatestModelAlias(modelId: string): boolean { - return latestModelAliasSet.has(modelId.trim().toLowerCase()); + return latestModelAliasSet.has(modelId); } diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts index b29c19fecf..62fd07f046 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts @@ -21,6 +21,7 @@ import { CLAUDE_HAIKU_LATEST_MODEL_ALIAS, CLAUDE_OPUS_LATEST_MODEL_ALIAS, CLAUDE_SONNET_LATEST_MODEL_ALIAS, + DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS, GEMINI_FLASH_LATEST_MODEL_ALIAS, GEMINI_PRO_LATEST_MODEL_ALIAS, GPT_LATEST_MODEL_ALIAS, @@ -43,6 +44,7 @@ describe('mapModelIdToVercel', () => { [GEMINI_PRO_LATEST_MODEL_ALIAS, GEMINI_PRO_CURRENT_VERCEL_MODEL_ID], [GEMINI_FLASH_LATEST_MODEL_ALIAS, GEMINI_FLASH_CURRENT_VERCEL_MODEL_ID], [GROK_LATEST_MODEL_ALIAS, GROK_CURRENT_VERCEL_MODEL_ID], + [DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS, 'deepseek/deepseek-v4-flash-0731'], ])('maps %s to the current Vercel model id', (input, expected) => { expect(mapModelIdToVercel(input)).toBe(expected); }); @@ -59,19 +61,19 @@ describe('mapModelIdToVercel', () => { GEMINI_PRO_LATEST_MODEL_ALIAS, GEMINI_FLASH_LATEST_MODEL_ALIAS, GROK_LATEST_MODEL_ALIAS, + DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS, ]); }); it('does not map a latest alias that is missing the leading tilde', () => { - expect(mapModelIdToVercel('anthropic/claude-opus-latest')).toBe( - 'anthropic/claude-opus-latest' + expect(mapModelIdToVercel('deepseek/deepseek-v4-flash-latest')).toBe( + 'deepseek/deepseek-v4-flash-latest' ); }); }); describe('hardcoded OpenRouter → Vercel mapping', () => { it.each([ - ['deepseek/deepseek-v4-flash-latest', 'deepseek/deepseek-v4-flash-0731'], ['mistralai/codestral-2508', 'mistral/codestral'], ['mistralai/devstral-2512', 'mistral/devstral-2'], ['mistralai/mistral-embed-2312', 'mistral/mistral-embed'], diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts index e1a1465061..123d739c9c 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts @@ -21,6 +21,7 @@ import { CLAUDE_HAIKU_LATEST_MODEL_ALIAS, CLAUDE_OPUS_LATEST_MODEL_ALIAS, CLAUDE_SONNET_LATEST_MODEL_ALIAS, + DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS, GEMINI_FLASH_LATEST_MODEL_ALIAS, GEMINI_PRO_LATEST_MODEL_ALIAS, GPT_LATEST_MODEL_ALIAS, @@ -40,7 +41,7 @@ const vercelModelIdMapping: Record = { [GEMINI_PRO_LATEST_MODEL_ALIAS]: GEMINI_PRO_CURRENT_VERCEL_MODEL_ID, [GEMINI_FLASH_LATEST_MODEL_ALIAS]: GEMINI_FLASH_CURRENT_VERCEL_MODEL_ID, [GROK_LATEST_MODEL_ALIAS]: GROK_CURRENT_VERCEL_MODEL_ID, - 'deepseek/deepseek-v4-flash-latest': 'deepseek/deepseek-v4-flash-0731', + [DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS]: 'deepseek/deepseek-v4-flash-0731', 'inclusionai/ling-3.0-flash:free': 'inclusionai/ling-3.0-flash-free', 'mistralai/codestral-2508': 'mistral/codestral', 'mistralai/devstral-2512': 'mistral/devstral-2',