Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/web/src/app/api/organizations/[id]/defaults/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/api/organizations/[id]/defaults/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/lib/ai-gateway/latest-model-aliases.ts
Original file line number Diff line number Diff line change
@@ -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<string>(LATEST_MODEL_ALIASES);

export function isLatestModelAlias(modelId: string): boolean {
return latestModelAliasSet.has(modelId);
}
14 changes: 14 additions & 0 deletions apps/web/src/lib/ai-gateway/llm-proxy-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() };
}
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/ai-gateway/model-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
35 changes: 24 additions & 11 deletions apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {
'~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',
Expand Down
76 changes: 73 additions & 3 deletions apps/web/src/lib/model-allow.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ 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<string, string[]>): ProviderLookup {
return async modelId => new Set(map[modelId] ?? []);
}

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);
Expand Down Expand Up @@ -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'],
Expand All @@ -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'],
},
Expand Down
Loading