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
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ jest.mock('@/lib/ai-gateway/models', () => {
gateway: 'alibaba',
internal_id: 'stub-internal',
pricing: null,
exclusive_to: [],
inference_provider_restriction: [],
};
return {
Expand Down
14 changes: 1 addition & 13 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ import { debugSaveProxyRequest } from '@/lib/debugUtils';
import { setTag, startInactiveSpan } from '@sentry/nextjs';
import { getUserFromAuth } from '@/lib/user/server';
import { sentryRootSpan } from '@/lib/getRootSpan';
import {
isDeadFreeModel,
isExcludedForFeature,
isKiloExclusiveFreeModel,
} from '@/lib/ai-gateway/models';
import { isDeadFreeModel, isKiloExclusiveFreeModel } from '@/lib/ai-gateway/models';
import {
hasBestEffortGuessDataCollectionRequirement,
isFreeModel,
Expand All @@ -40,7 +36,6 @@ import {
checkOrganizationModelRestrictions,
dataCollectionRequiredResponse,
extractFraudAndProjectHeaders,
featureExclusiveModelResponse,
invalidPathResponse,
invalidRequestResponse,
malformedJsonResponse,
Expand Down Expand Up @@ -344,13 +339,6 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

let effectiveModelIdLowerCased = requestBodyParsed.body.model.toLowerCase();

// Reject early (before rate limiting) if the model is exclusive to other features.
if (isExcludedForFeature(effectiveModelIdLowerCased, feature)) {
console.warn(
`Model ${effectiveModelIdLowerCased} is not available for feature ${feature}; rejecting.`
);
return featureExclusiveModelResponse(effectiveModelIdLowerCased);
}
if (!ipAddress) {
return NextResponse.json(
{
Expand Down
50 changes: 4 additions & 46 deletions apps/web/src/app/api/openrouter/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ jest.mock('@/lib/ai-gateway/byok', () => ({
addUserByokAvailability: jest.fn(),
getUserByokProviderIds: jest.fn(),
}));
jest.mock('@/lib/ai-gateway/models', () => ({
...jest.requireActual('@/lib/ai-gateway/models'),
filterByFeature: jest.fn(),
}));
jest.mock('@/lib/organizations/organization-models', () => ({
getAvailableModelsForOrganization: jest.fn(),
}));
Expand All @@ -46,7 +42,6 @@ const { getAvailableModelsForOrganization } = jest.requireMock(
);
const { getCachedRoutingTable } = jest.requireMock('@/lib/ai-gateway/auto-routing-table-cache');
const { getAutoFreeCandidates } = jest.requireMock('@/lib/ai-gateway/auto-model/resolution');
const { filterByFeature } = jest.requireMock('@/lib/ai-gateway/models');

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedGetEnhancedOpenRouterModels = jest.mocked(getEnhancedOpenRouterModels);
Expand All @@ -57,7 +52,6 @@ const mockedGetUserByokProviderIds = jest.mocked(getUserByokProviderIds);
const mockedGetAvailableModelsForOrganization = jest.mocked(getAvailableModelsForOrganization);
const mockedGetCachedRoutingTable = jest.mocked(getCachedRoutingTable);
const mockedGetAutoFreeCandidates = jest.mocked(getAutoFreeCandidates);
const mockedFilterByFeature = jest.mocked(filterByFeature);

function makeModel(id: string): OpenRouterModel {
return {
Expand All @@ -83,10 +77,6 @@ function request(headers?: Record<string, string>) {
describe('GET /api/openrouter/models', () => {
beforeEach(() => {
jest.resetAllMocks();
mockedFilterByFeature.mockImplementation(
(models: Array<{ id: string }>, feature: string | null) =>
feature === 'code-review' ? models.filter(model => model.id !== 'feature/excluded') : models
);
mockedGetUserFromAuth.mockResolvedValue({
user: null,
organizationId: null,
Expand Down Expand Up @@ -188,57 +178,25 @@ describe('GET /api/openrouter/models', () => {
});
});

test('builds auto-routing choices from feature-filtered models', async () => {
const efficientModel = makeModel('kilo-auto/efficient');
const allowedModel = makeModel('openai/gpt-5.4-mini');
const excludedModel = makeModel('feature/excluded');
mockedGetEnhancedOpenRouterModels.mockResolvedValue({
data: [efficientModel, allowedModel, excludedModel],
});
mockedGetCachedRoutingTable.mockResolvedValue({
routes: {
'implementation/code_generation': [{ model: allowedModel.id }, { model: excludedModel.id }],
},
} as never);

const response = await GET(request({ 'x-kilocode-feature': 'code-review' }));

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: [
{
...efficientModel,
autoRouting: {
models: [allowedModel.id],
},
},
allowedModel,
],
});
});

test('adds auto-routing to organization models after feature filtering', async () => {
test('adds auto-routing to organization models', async () => {
const efficientModel = makeModel('kilo-auto/efficient');
const allowedModel = makeModel('openai/gpt-5.4-mini');
const excludedModel = makeModel('feature/excluded');
mockedGetUserFromAuth.mockResolvedValue({
user: { id: 'user-id' },
organizationId: 'org-1',
authFailedResponse: null,
} as never);
mockedGetAvailableModelsForOrganization.mockResolvedValue({
data: [efficientModel, allowedModel, excludedModel],
data: [efficientModel, allowedModel],
} as never);
mockedGetCachedRoutingTable.mockResolvedValue({
routes: {
'implementation/code_generation': [{ model: allowedModel.id }, { model: excludedModel.id }],
'implementation/code_generation': [{ model: allowedModel.id }],
},
} as never);

const response = await GET(request({ 'x-kilocode-feature': 'code-review' }));
const response = await GET(request());

// The feature-excluded model must appear in neither the top-level list nor the
// Auto Efficient choices — enrichment runs after filterByFeature.
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: [
Expand Down
16 changes: 5 additions & 11 deletions apps/web/src/app/api/openrouter/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import { getEnhancedOpenRouterModels } from '@/lib/ai-gateway/providers/openrout
import { getUserFromAuth } from '@/lib/user/server';
import { getDirectByokModelsForUser } from '@/lib/ai-gateway/providers/direct-byok';
import { getAvailableModelsForOrganization } from '@/lib/organizations/organization-models';
import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection';
import { filterByFeature } from '@/lib/ai-gateway/models';
import { listAvailableExperimentModels } from '@/lib/ai-gateway/experiments/list-available-experiment-models';
import { addUserByokAvailability, getUserByokProviderIds } from '@/lib/ai-gateway/byok';
import { readDb } from '@/lib/drizzle';
Expand All @@ -27,9 +25,8 @@ async function tryGetUserFromAuth() {
* curl -vvv 'http://localhost:3000/api/openrouter/models'
*/
export async function GET(
request: NextRequest
_request: NextRequest
): Promise<NextResponse<{ error: string; message?: string } | OpenRouterModelsResponse>> {
const feature = validateFeatureHeader(request.headers.get(FEATURE_HEADER));
const auth = await tryGetUserFromAuth();
try {
const result = auth?.organizationId
Expand All @@ -38,19 +35,19 @@ export async function GET(
if (result) {
return NextResponse.json({
...result,
data: await addAutoRoutingModels(filterByFeature(result.data, feature)),
data: await addAutoRoutingModels(result.data),
});
}

const data = await getEnhancedOpenRouterModels();
if (!Array.isArray(data.data)) {
return NextResponse.json(data);
}
const models = await addAutoRoutingModels(filterByFeature(data.data, feature));
const models = await addAutoRoutingModels(data.data);
if (!auth?.user) {
const experimentModels = await listAvailableExperimentModels();
return NextResponse.json({
data: models.concat(filterByFeature(experimentModels, feature)),
data: models.concat(experimentModels),
});
}

Expand All @@ -64,10 +61,7 @@ export async function GET(
enabledByokProviderIds
);
return NextResponse.json({
data: modelsWithByokAvailability.concat(
filterByFeature(byokModels, feature),
filterByFeature(experimentModels, feature)
),
data: modelsWithByokAvailability.concat(byokModels, experimentModels),
});
} catch (error) {
captureException(error, {
Expand Down
10 changes: 3 additions & 7 deletions apps/web/src/app/api/openrouter/models/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import * as z from 'zod';
import { getEnhancedOpenRouterModels } from '@/lib/ai-gateway/providers/openrouter';
import { getUserFromAuth } from '@/lib/user/server';
import { getDirectByokModelsForUser } from '@/lib/ai-gateway/providers/direct-byok';
import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection';
import { ORGANIZATION_ID_HEADER } from '@/lib/constants';
import { filterByFeature } from '@/lib/ai-gateway/models';
import { listAvailableExperimentModels } from '@/lib/ai-gateway/experiments/list-available-experiment-models';

const BodySchema = z.object({ modelId: z.string().trim().min(1) });
Expand Down Expand Up @@ -44,7 +42,6 @@ export async function POST(request: NextRequest) {
);
}

const feature = validateFeatureHeader(request.headers.get(FEATURE_HEADER));
const auth = await tryGetUserFromAuth();
try {
const models = await getEnhancedOpenRouterModels();
Expand All @@ -55,10 +52,9 @@ export async function POST(request: NextRequest) {
auth?.user ? getDirectByokModelsForUser(auth.user.id) : [],
listAvailableExperimentModels(),
]);
const available = filterByFeature(
models.data.concat(byokModels, experimentModels),
feature
).some(model => model.id === bodyResult.data.modelId);
const available = models.data
.concat(byokModels, experimentModels)
.some(model => model.id === bodyResult.data.modelId);
return NextResponse.json(available ? { valid: true } : { valid: false, reason: 'unavailable' });
} catch (error) {
captureException(error, {
Expand Down
34 changes: 0 additions & 34 deletions apps/web/src/app/api/organizations/[id]/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,12 @@ jest.mock('@/lib/ai-gateway/auto-routing-table-cache', () => ({
jest.mock('@/lib/ai-gateway/auto-model/resolution', () => ({
getAutoFreeCandidates: jest.fn(),
}));
jest.mock('@/lib/ai-gateway/models', () => ({
...jest.requireActual('@/lib/ai-gateway/models'),
filterByFeature: jest.fn(),
}));

const mockedHandleTRPCRequest = jest.mocked(handleTRPCRequest);
const { getCachedRoutingTable } = jest.requireMock('@/lib/ai-gateway/auto-routing-table-cache');
const { getAutoFreeCandidates } = jest.requireMock('@/lib/ai-gateway/auto-model/resolution');
const { filterByFeature } = jest.requireMock('@/lib/ai-gateway/models');
const mockedGetCachedRoutingTable = jest.mocked(getCachedRoutingTable);
const mockedGetAutoFreeCandidates = jest.mocked(getAutoFreeCandidates);
const mockedFilterByFeature = jest.mocked(filterByFeature);
const listAvailableModels = jest.fn();

function makeModel(id: string): OpenRouterModel {
Expand Down Expand Up @@ -50,10 +44,6 @@ function request(headers?: Record<string, string>) {
describe('GET /api/organizations/[id]/models', () => {
beforeEach(() => {
jest.resetAllMocks();
mockedFilterByFeature.mockImplementation(
(models: Array<{ id: string }>, feature: string | null) =>
feature === 'code-review' ? models.filter(model => model.id !== 'feature/excluded') : models
);
mockedGetCachedRoutingTable.mockResolvedValue(null);
mockedGetAutoFreeCandidates.mockResolvedValue([]);
mockedHandleTRPCRequest.mockImplementation(async (request, handler) => {
Expand Down Expand Up @@ -90,30 +80,6 @@ describe('GET /api/organizations/[id]/models', () => {
});
});

test('builds auto-routing choices from the feature-filtered catalog', async () => {
const efficientModel = makeModel('kilo-auto/efficient');
const allowedModel = makeModel('openai/gpt-5.4-mini');
const excludedModel = makeModel('feature/excluded');
listAvailableModels.mockResolvedValue({
data: [efficientModel, allowedModel, excludedModel],
});
mockedGetCachedRoutingTable.mockResolvedValue({
routes: {
'implementation/code_generation': [{ model: allowedModel.id }, { model: excludedModel.id }],
},
} as never);

const response = await GET(request({ 'x-kilocode-feature': 'code-review' }), {
params: Promise.resolve({ id: 'org-1' }),
});

// The feature-excluded model must appear in neither the top-level list nor
// the Auto Efficient choices — enrichment runs after filterByFeature.
await expect(response.json()).resolves.toEqual({
data: [{ ...efficientModel, autoRouting: { models: [allowedModel.id] } }, allowedModel],
});
});

test('returns the catalog unchanged when auto models are absent', async () => {
const model = makeModel('openai/gpt-5.4-mini');
listAvailableModels.mockResolvedValue({ data: [model] });
Expand Down
6 changes: 1 addition & 5 deletions apps/web/src/app/api/organizations/[id]/models/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import type { NextRequest } from 'next/server';
import type { OpenRouterModelsResponse } from '@/lib/organizations/organization-types';
import { handleTRPCRequest } from '@/lib/trpc-route-handler';
import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection';
import { filterByFeature } from '@/lib/ai-gateway/models';
import { addAutoRoutingModels } from '@/lib/ai-gateway/auto-routing-models';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const organizationId = (await params).id;
const feature = validateFeatureHeader(request.headers.get(FEATURE_HEADER));

return handleTRPCRequest<OpenRouterModelsResponse>(request, async caller => {
const result = await caller.organizations.settings.listAvailableModels({
organizationId,
});
return {
...result,
data: await addAutoRoutingModels(filterByFeature(result.data, feature)),
data: await addAutoRoutingModels(result.data),
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import * as z from 'zod';
import { handleTRPCRequest } from '@/lib/trpc-route-handler';
import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection';
import { filterByFeature } from '@/lib/ai-gateway/models';

const BodySchema = z.object({ modelId: z.string().trim().min(1) });

Expand All @@ -29,13 +27,9 @@ export async function POST(
}

const organizationId = (await params).id;
const feature = validateFeatureHeader(request.headers.get(FEATURE_HEADER));

return handleTRPCRequest<ValidationResult>(request, async caller => {
const result = await caller.organizations.settings.listAvailableModels({ organizationId });
const available = filterByFeature(result.data, feature).some(
model => model.id === bodyResult.data.modelId
);
const available = result.data.some(model => model.id === bodyResult.data.modelId);
return available ? { valid: true } : { valid: false, reason: 'unavailable' };
});
}
9 changes: 0 additions & 9 deletions apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,15 +341,6 @@ export function organizationAutoConfigurationResponse(message: string) {
);
}

export function featureExclusiveModelResponse(modelId: string) {
const exclusiveTo = findKiloExclusiveModel(modelId)?.exclusive_to ?? [];
const error = `${modelId} is only available for ${exclusiveTo.join(', ')}. Use ${KILO_AUTO_FREE_MODEL.id} as a free alternative.`;
return NextResponse.json(
{ error, error_type: ProxyErrorType.feature_exclusive_model, message: error },
{ status: 403 }
);
}

export function storeAndPreviousResponseIdIsNotSupported() {
const error = 'The store and previous_response_id fields are not supported.';
return NextResponse.json(
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/lib/ai-gateway/model-api-kinds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ jest.mock('@/lib/ai-gateway/models', () => {
gateway: 'alibaba',
internal_id: 'stub-internal',
pricing: null,
exclusive_to: [],
inference_provider_restriction: [],
};
return {
Expand Down
22 changes: 0 additions & 22 deletions apps/web/src/lib/ai-gateway/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* Utility functions for working with AI models
*/

import type { FeatureValue } from '@/lib/feature-detection';
import {
KILO_AUTO_BALANCED_MODEL,
KILO_AUTO_EFFICIENT_MODEL,
Expand Down Expand Up @@ -132,24 +131,3 @@ export function isDeadFreeModel(model: string): boolean {
export function findKiloExclusiveModel(model: string): KiloExclusiveModel | null {
return kiloExclusiveModels.find(m => m.public_id === model && m.status !== 'disabled') ?? null;
}

/**
* Returns true if the model should be excluded for the given feature.
* A model is excluded when its `exclusive_to` list is non-empty, the feature is known,
* and the feature is not in `exclusive_to`.
* When feature is null (no header sent), the model is always included.
*/
export function isExcludedForFeature(modelId: string, feature: FeatureValue | null): boolean {
const model = kiloExclusiveModels.find(m => m.public_id === modelId);
if (!model?.exclusive_to.length) return false;
if (!feature) return false;
return !model.exclusive_to.includes(feature);
}

/** Filters out models that are not available for the given feature. */
export function filterByFeature<T extends { id: string }>(
models: T[],
feature: FeatureValue | null
): T[] {
return models.filter(m => !isExcludedForFeature(m.id, feature));
}
Loading