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 new file mode 100644 index 0000000000..f4826028ef --- /dev/null +++ b/apps/web/src/lib/ai-gateway/latest-model-aliases.ts @@ -0,0 +1,31 @@ +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 DEEPSEEK_V4_FLASH_LATEST_MODEL_ALIAS = '~deepseek/deepseek-v4-flash-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, + 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); +} 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/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/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts index 22eb48dff7..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 @@ -16,34 +16,64 @@ 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, + DEEPSEEK_V4_FLASH_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], + [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); }); + 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, + 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 fda05dcf44..123d739c9c 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts @@ -16,19 +16,32 @@ 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, + DEEPSEEK_V4_FLASH_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, - 'deepseek/deepseek-v4-flash-latest': 'deepseek/deepseek-v4-flash-0731', + [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_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', diff --git a/apps/web/src/lib/model-allow.server.test.ts b/apps/web/src/lib/model-allow.server.test.ts index 0046726171..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] ?? []); @@ -11,7 +12,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); @@ -53,12 +58,76 @@ 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('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('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 => { + 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'], @@ -72,6 +141,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 32fa72c9f7..922f78a319 100644 --- a/apps/web/src/lib/model-allow.server.ts +++ b/apps/web/src/lib/model-allow.server.ts @@ -1,16 +1,35 @@ import 'server-only'; -import { 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'; +import { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; 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) || + requestedModelId.startsWith(KILO_AUTO_MODEL_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 +37,26 @@ 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 (!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; } - if (!providerAllowSet) { - 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 +68,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..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 = {} @@ -38,6 +39,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 +56,104 @@ 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('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('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('preserves organization access when no model access policy is configured', async () => { + 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 => { + const policy = evaluateEffectiveModelAccessPolicy(context()); + + await expect( + getEffectiveModelDecision(policy, modelId, async () => new Set()) + ).resolves.toEqual({ allowed: true }); + } + ); + + it('allows known snapshot models 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 +187,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 +223,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..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 @@ -8,6 +8,8 @@ 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 { isLatestModelAlias } from '@/lib/ai-gateway/latest-model-aliases'; import { db } from '@/lib/drizzle'; import { getOrganizationGroupPolicyContext, @@ -15,6 +17,7 @@ import { } from '@/lib/organizations/organization-group-policy-context.server'; export type EffectiveOrganizationModelPolicy = { + requireModelInCurrentSnapshot: boolean; organizationModelDenyList: string[]; organizationProviderCeiling?: string[]; memberGrant: @@ -47,9 +50,9 @@ export function evaluateEffectiveModelAccessPolicy( const organizationProviderCeiling = organizationRestrictionsEnabled ? context.organization.settings.provider_allow_list : undefined; - if (!organizationRestrictionsEnabled) { return { + requireModelInCurrentSnapshot: false, organizationModelDenyList, organizationProviderCeiling, memberGrant: { mode: 'unrestricted' }, @@ -63,6 +66,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 +77,7 @@ export function evaluateEffectiveModelAccessPolicy( const selectedPolicies = policies.filter(policy => policy.data.mode === 'selected'); return { + requireModelInCurrentSnapshot: true, organizationModelDenyList, organizationProviderCeiling, memberGrant: { @@ -105,7 +110,19 @@ export async function getEffectiveModelDecision( providerLookup: ProviderLookup = getProviderSlugsForModel ): Promise { const normalizedModelId = normalizeModelId(modelId); - if (policy.organizationModelDenyList.includes(normalizedModelId)) { + if (await isModelRestrictionExempt(modelId)) { + return { allowed: true }; + } + // 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 && !latestAlias + ? await providerLookup(normalizedModelId) + : undefined; + if (currentModelProviders?.size === 0) { return { allowed: false, denialSource: 'organization_model' }; } const organizationRoutes = policy.organizationProviderCeiling @@ -114,9 +131,15 @@ export async function getEffectiveModelDecision( async function decisionWithinOrganizationCeiling(): Promise { if (!organizationRoutes) return { allowed: true }; - const modelProviders = await providerLookup(normalizedModelId); + 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: true, eligibleProviderRoutes: organizationRoutes }; + return { allowed: false, denialSource: 'organization_model' }; } const eligibleProviderRoutes = new Set( [...modelProviders].filter(provider => organizationRoutes.has(provider)) @@ -135,7 +158,9 @@ export async function getEffectiveModelDecision( if (policy.memberGrant.providerAllowList.length === 0) { return { allowed: false, denialSource: 'no_grant' }; } - const modelProviders = 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/lib/organizations/legacy-model-restrictions-parity.test.ts b/apps/web/src/lib/organizations/legacy-model-restrictions-parity.test.ts index 82ca83ef5c..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 @@ -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 () => { @@ -152,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-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 ); 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..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 { @@ -85,6 +86,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); @@ -341,6 +351,48 @@ 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('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: [ @@ -567,21 +619,37 @@ 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'])( + '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: modelId, + }); + + expect(result.settings.default_model).toBe(modelId); + } + ); + it('should throw UNAUTHORIZED error for non-owner users', async () => { const caller = await createCallerForUser(member.id); @@ -752,10 +820,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 ?? [], });