Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
60 changes: 60 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,60 @@
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';
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<string>(LATEST_MODEL_ALIASES);

const latestModelAliasTargets = new Map<string, string>([
[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());
}
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 @@ -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 {
Expand All @@ -16,24 +17,59 @@ 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,
resolveLatestModelAlias,
} 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('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'
Expand Down
32 changes: 22 additions & 10 deletions apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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,
[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',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/ai-gateway/providers/xai.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Loading
Loading