Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
45f2ace
Eliminate most uses of createProviderAwareModelAllowPredicate
chrarnoldus Mar 3, 2026
1cf6fd0
Remove isModelAllowedProviderAwareClient
chrarnoldus Mar 3, 2026
bb96063
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 4, 2026
3407a00
update comments
chrarnoldus Mar 4, 2026
39dda18
Remove dubious check
chrarnoldus Mar 4, 2026
0939d4d
Delete script
chrarnoldus Mar 4, 2026
f2c3fe2
Implement provider deny list in chat completions route
chrarnoldus Mar 4, 2026
dccfdb5
Vibes
chrarnoldus Mar 4, 2026
1ec443e
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 4, 2026
4db4d51
fix deny list: correct fallback model logic, providerConfig guard, an…
chrarnoldus Mar 4, 2026
048b3bf
Review comments addressed by agent
chrarnoldus Mar 4, 2026
21d27ad
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 4, 2026
0ef632e
Address review comments: normalize deny list entries, rename schema, …
chrarnoldus Mar 4, 2026
4633893
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 5, 2026
96169bf
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 5, 2026
f29030e
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 6, 2026
5e347e2
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 6, 2026
db4f4ae
Remove unused imports
chrarnoldus Mar 6, 2026
ed77282
This text doesn't make sense anymore
chrarnoldus Mar 6, 2026
9944ee2
Also filter models when all providers are denied
chrarnoldus Mar 6, 2026
333aff8
Move check earlier
chrarnoldus Mar 6, 2026
68ba142
Merge branch 'main' into christiaan/deny-list
chrarnoldus Mar 6, 2026
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
3 changes: 2 additions & 1 deletion packages/db/src/schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,11 @@ export const OrganizationPlanSchema = z.enum(['teams', 'enterprise']);
export type OrganizationPlan = z.infer<typeof OrganizationPlanSchema>;

const OrganizationSettingsSchema = z.object({
/** @deprecated use model_deny_list instead. delete if this is still here May 2026 */
model_allow_list: z.array(z.string()).optional(),
/** @deprecated use provider_deny_list instead. delete if this is still here May 2026 */
provider_allow_list: z.array(z.string()).optional(),

// under development, not yet enforced, will replace model_allow_list and provider_allow_list:
model_deny_list: z.array(z.string()).optional(),
provider_deny_list: z.array(z.string()).optional(),

Expand Down
38 changes: 18 additions & 20 deletions src/app/api/organizations/[id]/defaults/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createOrganization } from '@/lib/organizations/organizations';
import { db } from '@/lib/drizzle';
import { kilocode_users, organization_memberships, organizations } from '@kilocode/db/schema';
import type { OpenRouterModel } from '@/lib/organizations/organization-types';
import { PRIMARY_DEFAULT_MODEL } from '@/lib/models';

jest.mock('@/lib/organizations/organization-auth');
jest.mock('@/lib/providers/openrouter');
Expand Down Expand Up @@ -57,7 +58,7 @@ describe('GET /api/organizations/[id]/defaults', () => {
await db.delete(kilocode_users);
});

test('wildcard-only allow list returns an allowed preferred model (does not fall back to a disallowed global default)', async () => {
test('no deny list returns PRIMARY_DEFAULT_MODEL without calling OpenRouter', async () => {
const user = await insertTestUser();
const organization = await createOrganization('Test Org', user.id);

Expand All @@ -69,9 +70,7 @@ describe('GET /api/organizations/[id]/defaults', () => {
user: { ...user, role: 'owner' },
organization: {
...organization,
settings: {
model_allow_list: ['openai/*'],
},
settings: {},
},
},
});
Expand All @@ -82,20 +81,19 @@ describe('GET /api/organizations/[id]/defaults', () => {

expect(response.status).toBe(200);
const body = await response.json();

// The response must be a concrete model id (not the disallowed global default).
expect(body.defaultModel).toMatch(/^openai\//);
expect(body.defaultModel).toBe(PRIMARY_DEFAULT_MODEL);
expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled();
});

test('wildcard-only allow list falls back to the first allowed OpenRouter model when no preferred model matches', async () => {
test('deny list blocking PRIMARY_DEFAULT_MODEL falls back to first non-denied model from OpenRouter', async () => {
const user = await insertTestUser();
const organization = await createOrganization('Test Org', user.id);

mockedGetEnhancedOpenRouterModels.mockResolvedValue({
data: [
makeOpenRouterModel(PRIMARY_DEFAULT_MODEL),
makeOpenRouterModel('openai/gpt-4o'),
makeOpenRouterModel('example-provider/model-1'),
makeOpenRouterModel('some-other/model-2'),
],
});

Expand All @@ -106,7 +104,7 @@ describe('GET /api/organizations/[id]/defaults', () => {
organization: {
...organization,
settings: {
model_allow_list: ['example-provider/*'],
model_deny_list: [PRIMARY_DEFAULT_MODEL],
},
},
},
Expand All @@ -118,24 +116,24 @@ describe('GET /api/organizations/[id]/defaults', () => {

expect(response.status).toBe(200);
const body = await response.json();
expect(body.defaultModel).toBe('example-provider/model-1');
expect(mockedGetEnhancedOpenRouterModels).toHaveBeenCalledTimes(1);
// Should return the first non-denied model from OpenRouter
expect(body.defaultModel).toBe('openai/gpt-4o');
});

test('falls back to the first concrete allow-list entry when no global default is allowed', async () => {
test('org-configured default model is returned when not in deny list', async () => {
const user = await insertTestUser();
const organization = await createOrganization('Test Org', user.id);

mockedGetEnhancedOpenRouterModels.mockRejectedValue(new Error('should not be called'));

mockedGetAuthorizedOrgContext.mockResolvedValue({
success: true,
data: {
user: { ...user, role: 'owner' },
organization: {
...organization,
settings: {
model_allow_list: ['openai/gpt-5.2', 'openai/*'],
default_model: 'openai/gpt-4o',
model_deny_list: ['anthropic/claude-3-opus'],
},
},
},
Expand All @@ -147,11 +145,11 @@ describe('GET /api/organizations/[id]/defaults', () => {

expect(response.status).toBe(200);
const body = await response.json();
expect(body.defaultModel).toBe('openai/gpt-5.2');
expect(body.defaultModel).toBe('openai/gpt-4o');
expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled();
});

test('returns 409 when allow list exists but no models are allowed by it', async () => {
test('returns 409 when all models are denied', async () => {
const user = await insertTestUser();
const organization = await createOrganization('Test Org', user.id);

Expand All @@ -164,7 +162,7 @@ describe('GET /api/organizations/[id]/defaults', () => {
organization: {
...organization,
settings: {
model_allow_list: ['no-such-provider/*'],
model_deny_list: [PRIMARY_DEFAULT_MODEL],
},
},
},
Expand All @@ -177,8 +175,8 @@ describe('GET /api/organizations/[id]/defaults', () => {
expect(response.status).toBe(409);
const body = await response.json();
expect(body).toEqual({
error: "No valid models are allowed by this organization's allow list.",
error:
"No valid models are available — all models are blocked by this organization's deny list.",
});
expect(mockedGetEnhancedOpenRouterModels).toHaveBeenCalledTimes(1);
});
});
54 changes: 25 additions & 29 deletions src/app/api/organizations/[id]/defaults/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server';
import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth';
import type { NextRequest } from 'next/server';
import { preferredModels, PRIMARY_DEFAULT_MODEL } from '@/lib/models';
import { PRIMARY_DEFAULT_MODEL } from '@/lib/models';
import { getEnhancedOpenRouterModels } from '@/lib/providers/openrouter';
import { createProviderAwareModelAllowPredicate } from '@/lib/model-allow.server';
import { createAllowPredicateFromDenyList } from '@/lib/model-allow.server';
import { getModelIdToProviderSlugsIndex } from '@/lib/providers/openrouter/models-by-provider-index.server';
import { KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model';

Expand All @@ -27,9 +27,10 @@ export async function GET(
// Get organization's default model setting
let defaultModel = organization.settings?.default_model;

const allowList = organization.settings?.model_allow_list;
const modelDenyList = organization.settings?.model_deny_list;
const providerDenyList = organization.settings?.provider_deny_list;

const isAllowed = createProviderAwareModelAllowPredicate(allowList ?? []);
const isAllowed = createAllowPredicateFromDenyList(modelDenyList, providerDenyList);

const findFirstAllowedModel = async (modelIds: readonly string[]) => {
for (const modelId of modelIds) {
Expand Down Expand Up @@ -71,34 +72,29 @@ export async function GET(

// Fallback to global default if no organization default is set or it's not allowed
if (!defaultModel) {
defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL]);

if (!defaultModel) {
if (!allowList?.length) {
defaultModel = PRIMARY_DEFAULT_MODEL;
} else {
const firstConcreteAllowedModel = allowList.find(modelId => !modelId.endsWith('/*'));
defaultModel = firstConcreteAllowedModel;
if (!modelDenyList?.length && !providerDenyList?.length) {
// No restrictions - use PRIMARY_DEFAULT_MODEL directly
defaultModel = PRIMARY_DEFAULT_MODEL;
} else {
defaultModel = await findFirstAllowedModel([PRIMARY_DEFAULT_MODEL]);

if (!defaultModel) {
defaultModel = await findFirstAllowedModelFromDbSnapshot();
}
}

if (!defaultModel && allowList?.length) {
defaultModel = await findFirstAllowedModel(preferredModels);
}

if (!defaultModel && allowList?.length) {
defaultModel = await findFirstAllowedModelFromDbSnapshot();
}

if (!defaultModel && allowList?.length) {
defaultModel = await findFirstAllowedModelFromOpenRouter();
}
if (!defaultModel) {
defaultModel = await findFirstAllowedModelFromOpenRouter();
}

if (!defaultModel) {
return NextResponse.json(
{ error: "No valid models are allowed by this organization's allow list." },
{ status: 409 }
);
if (!defaultModel) {
return NextResponse.json(
{
error:
"No valid models are available — all models are blocked by this organization's deny list.",
},
{ status: 409 }
);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/models/CondensedProviderAndModelsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function CondensedProviderAndModelsList({
}

if (!selections || providersWithSelections.length === 0) {
return <div className="text-muted-foreground text-sm">No providers selected</div>;
return null;
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,53 @@ import { describe, test, expect } from '@jest/globals';
import { computeProviderSelectionsForSummaryCard } from './OrganizationProvidersAndModelsConfigurationCard';

describe('computeProviderSelectionsForSummaryCard', () => {
test('expands provider wildcard entries (e.g., anthropic/*) when provider is allowed', () => {
test('both deny lists empty returns null (all providers and models)', () => {
const openRouterProviders = [
{
slug: 'anthropic',
models: [
{ slug: 'anthropic/claude-3-opus', endpoint: 'chat' },
{ slug: 'anthropic/claude-3-sonnet', endpoint: 'chat' },
{ slug: 'anthropic/disabled-model' },
],
},
];

const selections = computeProviderSelectionsForSummaryCard({
openRouterProviders,
providerAllowList: ['anthropic'],
modelAllowList: ['anthropic/*'],
providerDenyList: [],
modelDenyList: [],
});

expect(selections).toBeNull();
});

test('providerDenyList excludes denied providers', () => {
const openRouterProviders = [
{
slug: 'openai',
models: [{ slug: 'openai/gpt-4', endpoint: 'chat' }],
},
{
slug: 'anthropic',
models: [{ slug: 'anthropic/claude-3-opus', endpoint: 'chat' }],
},
];

const selections = computeProviderSelectionsForSummaryCard({
openRouterProviders,
providerDenyList: ['openai'],
modelDenyList: [],
});

expect(selections).toEqual([
{
slug: 'anthropic',
models: ['anthropic/claude-3-opus', 'anthropic/claude-3-sonnet'],
models: ['anthropic/claude-3-opus'],
},
]);
});

test('keeps existing exact-match behavior for model allow list entries', () => {
test('modelDenyList excludes denied models', () => {
const openRouterProviders = [
{
slug: 'anthropic',
Expand All @@ -41,23 +61,23 @@ describe('computeProviderSelectionsForSummaryCard', () => {

const selections = computeProviderSelectionsForSummaryCard({
openRouterProviders,
providerAllowList: ['anthropic'],
modelAllowList: ['anthropic/claude-3-opus'],
providerDenyList: [],
modelDenyList: ['anthropic/claude-3-opus'],
});

expect(selections).toEqual([
{
slug: 'anthropic',
models: ['anthropic/claude-3-opus'],
models: ['anthropic/claude-3-sonnet'],
},
]);
});

test('supports wildcard-only model allow lists even when provider allow list is empty', () => {
test('combined deny lists exclude both providers and models', () => {
const openRouterProviders = [
{
slug: 'openai',
models: [{ slug: 'openai/gpt-4.1', endpoint: 'chat' }],
models: [{ slug: 'openai/gpt-4', endpoint: 'chat' }],
},
{
slug: 'anthropic',
Expand All @@ -70,37 +90,53 @@ describe('computeProviderSelectionsForSummaryCard', () => {

const selections = computeProviderSelectionsForSummaryCard({
openRouterProviders,
providerAllowList: [],
modelAllowList: ['anthropic/*'],
providerDenyList: ['openai'],
modelDenyList: ['anthropic/claude-3-opus'],
});

expect(selections).toEqual([
{
slug: 'anthropic',
models: ['anthropic/claude-3-opus', 'anthropic/claude-3-sonnet'],
models: ['anthropic/claude-3-sonnet'],
},
]);
});

test('supports provider-membership wildcard when model namespace differs (e.g. cerebras/* allows z-ai/glm4.6)', () => {
test('returns empty array when all providers are denied (distinct from null which means no restrictions)', () => {
const openRouterProviders = [
{
slug: 'cerebras',
models: [{ slug: 'z-ai/glm4.6', endpoint: 'chat' }],
slug: 'openai',
models: [{ slug: 'openai/gpt-4', endpoint: 'chat' }],
},
];

const selections = computeProviderSelectionsForSummaryCard({
openRouterProviders,
providerAllowList: ['cerebras'],
modelAllowList: ['cerebras/*'],
providerDenyList: ['openai'],
modelDenyList: [],
});

expect(selections).toEqual([
expect(selections).toEqual([]);
});

test('models without endpoint are excluded', () => {
const openRouterProviders = [
{
slug: 'cerebras',
models: ['z-ai/glm4.6'],
slug: 'anthropic',
models: [
{ slug: 'anthropic/claude-3-opus', endpoint: 'chat' },
{ slug: 'anthropic/disabled-model' },
],
},
]);
];

const selections = computeProviderSelectionsForSummaryCard({
openRouterProviders,
providerDenyList: [],
modelDenyList: ['anthropic/claude-3-opus'],
});

// Both models are excluded (one by deny list, one by no endpoint); deny list is non-empty so [] not null
expect(selections).toEqual([]);
});
});
Loading