Skip to content
10 changes: 10 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { sentryRootSpan } from '@/lib/getRootSpan';
import {
isFreeModel,
isDeadFreeModel,
isExcludedForFeature,
isKiloExclusiveFreeModel,
isKiloStealthModel,
} from '@/lib/models';
Expand All @@ -32,6 +33,7 @@ import {
checkOrganizationModelRestrictions,
dataCollectionRequiredResponse,
extractFraudAndProjectHeaders,
featureExclusiveModelResponse,
invalidPathResponse,
invalidRequestResponse,
makeErrorReadable,
Expand Down Expand Up @@ -227,6 +229,14 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

const originalModelIdLowerCased = requestBodyParsed.body.model.toLowerCase();

// Reject early (before rate limiting) if the model is exclusive to other features.
if (isExcludedForFeature(originalModelIdLowerCased, feature)) {
console.warn(
`Model ${originalModelIdLowerCased} is not available for feature ${feature}; rejecting.`
);
return featureExclusiveModelResponse(originalModelIdLowerCased);
}

// Extract IP for all requests (needed for free model rate limiting)
const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
if (!ipAddress) {
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/app/api/openrouter/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { getUserFromAuth } from '@/lib/user.server';
import { getDirectByokModelsForUser } from '@/lib/providers/direct-byok';
import { unstable_cache } from 'next/cache';
import { getAvailableModelsForOrganization } from '@/lib/organizations/organization-models';
import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection';
import { filterByFeature } from '@/lib/models';

const getDirectByokModels = unstable_cache(
(userId: string) => getDirectByokModelsForUser(userId),
Expand All @@ -28,23 +30,27 @@ 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
? await getAvailableModelsForOrganization(auth.organizationId)
: null;
if (result) {
return NextResponse.json(result);
return NextResponse.json({
...result,
data: filterByFeature(result.data, feature),
});
}

const data = await getEnhancedOpenRouterModels();
if (!Array.isArray(data.data)) {
return NextResponse.json(data);
}
const byokModels = auth?.user ? await getDirectByokModels(auth.user.id) : [];
return NextResponse.json({ data: data.data.concat(byokModels) });
return NextResponse.json({ data: filterByFeature(data.data.concat(byokModels), feature) });
} catch (error) {
captureException(error, {
tags: { endpoint: 'openrouter/models' },
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/api/organizations/[id]/models/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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/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 => {
return caller.organizations.settings.listAvailableModels({ organizationId });
const result = await caller.organizations.settings.listAvailableModels({ organizationId });
return { ...result, data: filterByFeature(result.data, feature) };
});
}
6 changes: 6 additions & 0 deletions apps/web/src/lib/llm-proxy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ export function modelDoesNotExistResponse() {
);
}

export function featureExclusiveModelResponse(modelId: string) {
const exclusiveTo = kiloExclusiveModels.find(m => m.public_id === 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, message: error }, { status: 403 });
}

export function storeAndPreviousResponseIdIsNotSupported() {
const error = 'The store and previous_response_id fields are not supported.';
return NextResponse.json({ error, message: error }, { status: 400 });
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Utility functions for working with AI models
*/

import type { FeatureValue } from '@/lib/feature-detection';
import {
KILO_AUTO_BALANCED_MODEL,
KILO_AUTO_FREE_MODEL,
Expand Down Expand Up @@ -91,3 +92,24 @@ 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));
}
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/anthropic.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export const claude_sonnet_clawsetup_model: KiloExclusiveModel = {
flags: ['reasoning', 'vision'],
inference_provider: null,
pricing: null,
exclusive_to: [],
};
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/arcee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export const trinity_large_thinking_free_model: KiloExclusiveModel = {
internal_id: 'arcee-ai/trinity-large-thinking',
inference_provider: 'arcee-ai',
pricing: null,
exclusive_to: ['kiloclaw', 'openclaw'],
};
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/bytedance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export const seed_20_pro_free_model: KiloExclusiveModel = {
internal_id: 'seed-2-0-pro-260328',
inference_provider: 'seed',
pricing: null,
exclusive_to: [],
};
3 changes: 3 additions & 0 deletions apps/web/src/lib/providers/kilo-exclusive-model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FeatureValue } from '@/lib/feature-detection';
import type { OpenRouterInferenceProviderId } from '@/lib/providers/openrouter/inference-provider-id';
import type { ProviderId } from '@/lib/providers/types';

Expand Down Expand Up @@ -30,6 +31,8 @@ export type KiloExclusiveModel = {
internal_id: string;
inference_provider: OpenRouterInferenceProviderId | null;
pricing: Pricing | null;
/** Features allowed to use this model. Empty array means no restriction. */
exclusive_to: ReadonlyArray<FeatureValue>;
};

function formatPricePerMillionAsPerToken(price: number): string;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/minimax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const minimax_m25_free_model: KiloExclusiveModel = {
internal_id: 'minimax/minimax-m2.5',
inference_provider: null,
pricing: null,
exclusive_to: [],
};

export function isMinimaxModel(model: string) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/morph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export const morph_warp_grep_free_model: KiloExclusiveModel = {
internal_id: 'morph-warp-grep-v2',
inference_provider: 'morph',
pricing: null,
exclusive_to: [],
};
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export const gpt_oss_20b_free_model: KiloExclusiveModel = {
internal_id: 'openai/gpt-oss-20b',
inference_provider: null,
pricing: null,
exclusive_to: [],
};
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/qwen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ export const qwen36_plus_model: KiloExclusiveModel = {
);
},
},
exclusive_to: [],
};
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/stepfun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export const stepfun_35_flash_free_model: KiloExclusiveModel = {
internal_id: 'stepfun/step-3.5-flash',
inference_provider: null,
pricing: null,
exclusive_to: [],
};
1 change: 1 addition & 0 deletions apps/web/src/lib/providers/xai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const grok_code_fast_1_optimized_free_model: KiloExclusiveModel = {
internal_id: 'x-ai/grok-code-fast-1:optimized',
inference_provider: 'stealth',
pricing: null,
exclusive_to: [],
};

export function isXaiModel(requestedModel: string) {
Expand Down
Loading