diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index 4e8c1452a..731082e1a 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -23,6 +23,7 @@ import { sentryRootSpan } from '@/lib/getRootSpan'; import { isFreeModel, isDeadFreeModel, + isExcludedForFeature, isKiloExclusiveFreeModel, isKiloStealthModel, } from '@/lib/models'; @@ -32,6 +33,7 @@ import { checkOrganizationModelRestrictions, dataCollectionRequiredResponse, extractFraudAndProjectHeaders, + featureExclusiveModelResponse, invalidPathResponse, invalidRequestResponse, makeErrorReadable, @@ -227,6 +229,14 @@ export async function POST(request: NextRequest): Promise getDirectByokModelsForUser(userId), @@ -28,15 +30,19 @@ async function tryGetUserFromAuth() { * curl -vvv 'http://localhost:3000/api/openrouter/models' */ export async function GET( - _request: NextRequest + request: NextRequest ): Promise> { + 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(); @@ -44,7 +50,7 @@ export async function GET( 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' }, diff --git a/apps/web/src/app/api/organizations/[id]/models/route.ts b/apps/web/src/app/api/organizations/[id]/models/route.ts index 295f785a1..cbeea6729 100644 --- a/apps/web/src/app/api/organizations/[id]/models/route.ts +++ b/apps/web/src/app/api/organizations/[id]/models/route.ts @@ -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(request, async caller => { - return caller.organizations.settings.listAvailableModels({ organizationId }); + const result = await caller.organizations.settings.listAvailableModels({ organizationId }); + return { ...result, data: filterByFeature(result.data, feature) }; }); } diff --git a/apps/web/src/lib/llm-proxy-helpers.ts b/apps/web/src/lib/llm-proxy-helpers.ts index 011039a72..4e957d5ee 100644 --- a/apps/web/src/lib/llm-proxy-helpers.ts +++ b/apps/web/src/lib/llm-proxy-helpers.ts @@ -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 }); diff --git a/apps/web/src/lib/models.ts b/apps/web/src/lib/models.ts index 7a485c903..cd718721c 100644 --- a/apps/web/src/lib/models.ts +++ b/apps/web/src/lib/models.ts @@ -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, @@ -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( + models: T[], + feature: FeatureValue | null +): T[] { + return models.filter(m => !isExcludedForFeature(m.id, feature)); +} diff --git a/apps/web/src/lib/providers/anthropic.constants.ts b/apps/web/src/lib/providers/anthropic.constants.ts index c5ce8cfa7..7eeb7b99c 100644 --- a/apps/web/src/lib/providers/anthropic.constants.ts +++ b/apps/web/src/lib/providers/anthropic.constants.ts @@ -20,4 +20,5 @@ export const claude_sonnet_clawsetup_model: KiloExclusiveModel = { flags: ['reasoning', 'vision'], inference_provider: null, pricing: null, + exclusive_to: [], }; diff --git a/apps/web/src/lib/providers/arcee.ts b/apps/web/src/lib/providers/arcee.ts index 3dc740e92..b73a0fc48 100644 --- a/apps/web/src/lib/providers/arcee.ts +++ b/apps/web/src/lib/providers/arcee.ts @@ -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'], }; diff --git a/apps/web/src/lib/providers/bytedance.ts b/apps/web/src/lib/providers/bytedance.ts index 6c4bc3f4f..60086b6e6 100644 --- a/apps/web/src/lib/providers/bytedance.ts +++ b/apps/web/src/lib/providers/bytedance.ts @@ -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: [], }; diff --git a/apps/web/src/lib/providers/kilo-exclusive-model.ts b/apps/web/src/lib/providers/kilo-exclusive-model.ts index e6a1361c4..44111ec2e 100644 --- a/apps/web/src/lib/providers/kilo-exclusive-model.ts +++ b/apps/web/src/lib/providers/kilo-exclusive-model.ts @@ -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'; @@ -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; }; function formatPricePerMillionAsPerToken(price: number): string; diff --git a/apps/web/src/lib/providers/minimax.ts b/apps/web/src/lib/providers/minimax.ts index 7840618c7..1ab672e95 100644 --- a/apps/web/src/lib/providers/minimax.ts +++ b/apps/web/src/lib/providers/minimax.ts @@ -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) { diff --git a/apps/web/src/lib/providers/morph.ts b/apps/web/src/lib/providers/morph.ts index 783cc5fee..05b1e1f32 100644 --- a/apps/web/src/lib/providers/morph.ts +++ b/apps/web/src/lib/providers/morph.ts @@ -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: [], }; diff --git a/apps/web/src/lib/providers/openai.ts b/apps/web/src/lib/providers/openai.ts index 072297254..5ecd76f3b 100644 --- a/apps/web/src/lib/providers/openai.ts +++ b/apps/web/src/lib/providers/openai.ts @@ -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: [], }; diff --git a/apps/web/src/lib/providers/qwen.ts b/apps/web/src/lib/providers/qwen.ts index 5eadbc219..ca1ca3f45 100644 --- a/apps/web/src/lib/providers/qwen.ts +++ b/apps/web/src/lib/providers/qwen.ts @@ -37,4 +37,5 @@ export const qwen36_plus_model: KiloExclusiveModel = { ); }, }, + exclusive_to: [], }; diff --git a/apps/web/src/lib/providers/stepfun.ts b/apps/web/src/lib/providers/stepfun.ts index b9ed71660..28d6eb833 100644 --- a/apps/web/src/lib/providers/stepfun.ts +++ b/apps/web/src/lib/providers/stepfun.ts @@ -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: [], }; diff --git a/apps/web/src/lib/providers/xai.ts b/apps/web/src/lib/providers/xai.ts index f56217518..ea82adfed 100644 --- a/apps/web/src/lib/providers/xai.ts +++ b/apps/web/src/lib/providers/xai.ts @@ -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) {