Public ID may only contain lowercase letters, digits, and . / -. It
- must not start with kilo/, kilo-auto/,{' '}
- kilocode/, or a direct BYOK provider prefix, and must not be an
- OpenRouter or Kilo-exclusive model.
+ must not start with kilo/, kilo-auto/, or{' '}
+ kilocode/, be an OpenRouter model, or use a direct BYOK provider
+ prefix. It may reuse a Kilo-exclusive model ID, which the custom LLM then replaces.
+
+
+ Custom LLMs are always free for users. To make the model available to every user,
+ replace organization_ids and group_ids with{' '}
+
+ "public": {'{'} "inference_providers": ["acme"]{' '}
+ {'}'}
+ {' '}
+ and add a description. Organization provider allow lists apply to these
+ providers. Unknown providers appear in the provider list after the next provider
+ sync. Saved changes reach every server within a minute.
@@ -528,6 +601,36 @@ export function CustomLlmsContent() {
+ {
+ if (!open) setPendingPublicChange(null);
+ }}
+ >
+
+
+ Make this custom LLM public?
+
+
+
+ Every Kilo user, including signed-out users, will be able to use{' '}
+ {pendingPublicChange?.input.public_id} for
+ free. Kilo pays for all usage.
+
+
+ Test this definition with a local web app before publishing it. A broken
+ definition fails requests for everyone.
+
+
+
+
+
+ Cancel
+ Make public
+
+
+
+
{
- if (!open) setPendingPublicChange(null);
+ if (!open) setPendingChange(null);
}}
>
- Make this custom LLM public?
+
+ {pendingChange?.makesPublic
+ ? 'Make this custom LLM public?'
+ : 'Replace a Kilo-exclusive model?'}
+
+ {pendingChange?.makesPublic && (
+
+ Every Kilo user, including signed-out users, will be able to use{' '}
+ {pendingChange.input.public_id} for free.
+ Kilo pays for all usage.
+
+ {pendingChange.input.public_id} is a
+ Kilo-exclusive model. This custom LLM replaces it for every user: anyone it is
+ not available to gets a model-not-found error instead.
+
+ )}
- Every Kilo user, including signed-out users, will be able to use{' '}
- {pendingPublicChange?.input.public_id} for
- free. Kilo pays for all usage.
-
-
- Test this definition with a local web app before publishing it. A broken
- definition fails requests for everyone.
+ Test this definition with a local web app before saving it. A broken definition
+ fails requests for everyone who can use it.
Cancel
- Make public
+
+ {pendingChange?.makesPublic ? 'Make public' : 'Replace model'}
+
diff --git a/apps/web/src/routers/admin/custom-llm-router.test.ts b/apps/web/src/routers/admin/custom-llm-router.test.ts
index 32c8e00866d..ea6bd8f1257 100644
--- a/apps/web/src/routers/admin/custom-llm-router.test.ts
+++ b/apps/web/src/routers/admin/custom-llm-router.test.ts
@@ -272,6 +272,16 @@ describe('adminCustomLlmRouter', () => {
expect(item!.public_id).toBe(publicId);
expect((item as Record).encrypted_api_key).toBeUndefined();
});
+
+ it('lists the Kilo-exclusive model ids a new custom LLM would replace', async () => {
+ const caller = await createCallerForUser(admin.id);
+
+ const listResult = await caller.admin.customLlm.list();
+
+ expect(listResult.kiloExclusiveModelIds).toEqual(
+ kiloExclusiveModels.map(model => model.public_id)
+ );
+ });
});
describe('copy', () => {
diff --git a/apps/web/src/routers/admin/custom-llm-router.ts b/apps/web/src/routers/admin/custom-llm-router.ts
index 9142e155ade..e4df9ca714a 100644
--- a/apps/web/src/routers/admin/custom-llm-router.ts
+++ b/apps/web/src/routers/admin/custom-llm-router.ts
@@ -14,7 +14,10 @@ import { encryptApiKey } from '@kilocode/web-shared/lib/ai-gateway/byok/encrypti
import { BYOK_ENCRYPTION_KEY } from '@kilocode/web-shared/lib/config.server';
import { DirectUserByokInferenceProviderIdSchema } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { getOpenRouterModelsMetadataFromDatabase } from '@kilocode/web-shared/lib/ai-gateway/providers/gateway-models-cache';
-import { isKiloExclusiveModel } from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
+import {
+ isKiloExclusiveModel,
+ kiloExclusiveModels,
+} from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
const publicIdSchema = z.string().trim().min(1, 'public_id is required');
@@ -87,7 +90,11 @@ export const adminCustomLlmRouter = createTRPCRouter({
})
.from(custom_llm2)
.orderBy(asc(custom_llm2.public_id));
- return { items: rows };
+ // The editor warns before a new custom LLM replaces one of these models.
+ return {
+ items: rows,
+ kiloExclusiveModelIds: kiloExclusiveModels.map(model => model.public_id),
+ };
}),
upsert: adminProcedure.input(UpsertCustomLlmSchema).mutation(async ({ input }) => {
diff --git a/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.test.ts b/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.test.ts
index f83686ac888..b6fad2a26c6 100644
--- a/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.test.ts
+++ b/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.test.ts
@@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/gl
import { custom_llm2 } from '@kilocode/db/schema';
import {
findCustomLlm,
- getPublicCustomLlmInferenceProviders,
+ getCustomLlmInferenceProviderRestriction,
isCustomLlm,
lookupCustomLlm,
requireCustomLlmsById,
@@ -18,7 +18,10 @@ import {
publicCustomLlmDefinition,
} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
-const exclusiveId = kiloExclusiveModels[0].public_id;
+const exclusiveId =
+ kiloExclusiveModels.find(model => !/[:~]/.test(model.public_id))?.public_id ?? '';
+const unclaimableExclusiveId =
+ kiloExclusiveModels.find(model => model.public_id.includes(':'))?.public_id ?? '';
describe('custom LLM catalog', () => {
beforeAll(async () => {
@@ -73,7 +76,9 @@ describe('custom LLM catalog', () => {
});
await expect(lookupCustomLlm(exclusiveId)).resolves.toEqual({ kind: 'unknown' });
+ await expect(lookupCustomLlm(unclaimableExclusiveId)).resolves.toEqual({ kind: 'none' });
await expect(lookupCustomLlm('acme/public-model')).resolves.toEqual({ kind: 'none' });
+ await expect(getCustomLlmInferenceProviderRestriction(exclusiveId)).resolves.toEqual(new Set());
await expect(findCustomLlm(exclusiveId)).resolves.toBeNull();
await expect(requireCustomLlmsById()).rejects.toThrow('custom LLM catalog could not be read');
expect(select).toHaveBeenCalledTimes(1);
@@ -92,11 +97,14 @@ describe('custom LLM catalog', () => {
await expect(isFreeModel(exclusiveId)).resolves.toBe(true);
});
- test('returns inference providers only for public custom LLMs', async () => {
- await expect(getPublicCustomLlmInferenceProviders('acme/public-model')).resolves.toEqual(
+ test('pins public custom LLMs to their providers and private ones to none', async () => {
+ await expect(getCustomLlmInferenceProviderRestriction('acme/public-model')).resolves.toEqual(
new Set(['acme'])
);
- await expect(getPublicCustomLlmInferenceProviders('acme/private-model')).resolves.toBeNull();
- await expect(getPublicCustomLlmInferenceProviders('openai/gpt-4o')).resolves.toBeNull();
+ await expect(getCustomLlmInferenceProviderRestriction('acme/private-model')).resolves.toEqual(
+ new Set()
+ );
+ await expect(getCustomLlmInferenceProviderRestriction(exclusiveId)).resolves.toEqual(new Set());
+ await expect(getCustomLlmInferenceProviderRestriction('openai/gpt-4o')).resolves.toBeNull();
});
});
diff --git a/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.ts b/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.ts
index a6f01a7c0d8..362272f7cc3 100644
--- a/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.ts
+++ b/packages/web-shared/src/lib/ai-gateway/custom-llm/custom-llm-catalog.ts
@@ -1,6 +1,10 @@
import 'server-only';
import { custom_llm2, type CustomLlm2 } from '@kilocode/db/schema';
-import { CustomLlmDefinitionSchema, type CustomLlmDefinition } from '@kilocode/db/schema-types';
+import {
+ CustomLlmDefinitionSchema,
+ CustomLlmPublicIdSchema,
+ type CustomLlmDefinition,
+} from '@kilocode/db/schema-types';
import { readDb } from '@kilocode/web-shared/lib/drizzle';
import { kiloExclusiveModels } from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import { captureException } from '@sentry/nextjs';
@@ -140,9 +144,10 @@ export type CustomLlmLookup =
/**
* Resolves a model id for routing. While the catalog is unavailable, an id is
- * `unknown` only if it is a Kilo-exclusive id: a custom LLM may shadow it, and
- * routing must not fall back to the exclusive model. Any other custom LLM id
- * matches no built-in model, so treating it as built-in fails on its own.
+ * `unknown` only if it is a Kilo-exclusive id that a custom LLM could claim:
+ * a custom LLM may shadow it, and routing must not fall back to the exclusive
+ * model. Any other custom LLM id matches no built-in model, so treating it as
+ * built-in fails on its own.
*/
export async function lookupCustomLlm(modelId: string): Promise {
const normalizedModelId = modelId.trim().toLowerCase();
@@ -151,6 +156,7 @@ export async function lookupCustomLlm(modelId: string): Promise
if (customLlm) return { kind: 'custom-llm', customLlm };
if (
!catalog.available &&
+ CustomLlmPublicIdSchema.safeParse(normalizedModelId).success &&
kiloExclusiveModels.some(model => model.public_id === normalizedModelId)
) {
return { kind: 'unknown' };
@@ -168,14 +174,18 @@ export async function isCustomLlm(modelId: string): Promise {
}
/**
- * Inference providers a model id is restricted to by a public custom LLM, or
- * null when the id is not a public custom LLM.
+ * Inference providers a custom LLM pins its id to, or null when the id is not
+ * a custom LLM. A private custom LLM pins no providers: callers it is granted
+ * to are exempt from model policy, and nobody else may use it, even when it
+ * reuses a Kilo-exclusive id. The same holds while the catalog is unavailable
+ * for an id a custom LLM could shadow.
*/
-export async function getPublicCustomLlmInferenceProviders(
+export async function getCustomLlmInferenceProviderRestriction(
modelId: string
): Promise | null> {
- const customLlm = await findCustomLlm(modelId);
- return customLlm && isPublicCustomLlm(customLlm.definition)
- ? new Set(customLlm.definition.public.inference_providers)
- : null;
+ const lookup = await lookupCustomLlm(modelId);
+ if (lookup.kind === 'none') return null;
+ if (lookup.kind === 'unknown') return new Set();
+ const { definition } = lookup.customLlm;
+ return new Set(isPublicCustomLlm(definition) ? definition.public.inference_providers : []);
}
diff --git a/packages/web-shared/src/lib/model-allow.server.test.ts b/packages/web-shared/src/lib/model-allow.server.test.ts
index abd58a8bf01..0237df89456 100644
--- a/packages/web-shared/src/lib/model-allow.server.test.ts
+++ b/packages/web-shared/src/lib/model-allow.server.test.ts
@@ -293,6 +293,26 @@ describe('model access predicates', () => {
await expect(isAllowed('test/restricted-exclusive')).resolves.toBe(true);
});
+ test('pins a private custom LLM that reuses an exclusive id to no providers', async () => {
+ await insertCustomLlmForTest(
+ 'test/restricted-exclusive',
+ customLlmDefinition({ organization_ids: ['00000000-0000-4000-8000-0000000000a1'] })
+ );
+ try {
+ const isAllowed = createAllowPredicateFromProviderAllowList(
+ undefined,
+ ['stepfun'],
+ lookup({}),
+ true,
+ { organizationId: '00000000-0000-4000-8000-0000000000a2', groupIds: [] }
+ );
+
+ await expect(isAllowed('test/restricted-exclusive')).resolves.toBe(false);
+ } finally {
+ await deleteCustomLlmForTest('test/restricted-exclusive');
+ }
+ });
+
test('provider allow list still evaluates restricted exclusive models missing from the snapshot', async () => {
const isAllowed = createAllowPredicateFromProviderAllowList(undefined, ['stepfun'], lookup({}));
diff --git a/packages/web-shared/src/lib/model-allow.server.ts b/packages/web-shared/src/lib/model-allow.server.ts
index 47d3ebe4ca6..8dc7ed80dbf 100644
--- a/packages/web-shared/src/lib/model-allow.server.ts
+++ b/packages/web-shared/src/lib/model-allow.server.ts
@@ -6,7 +6,7 @@ import {
} from '@kilocode/web-shared/lib/ai-gateway/model-utils';
import {
findCustomLlm,
- getPublicCustomLlmInferenceProviders,
+ getCustomLlmInferenceProviderRestriction,
} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import { hasCustomLlmAccess } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/access';
import { getDirectByokModel } from '@kilocode/web-shared/lib/ai-gateway/providers/direct-byok';
@@ -56,15 +56,15 @@ export async function isModelRestrictionExempt(
}
/**
- * Providers a model is pinned to regardless of the snapshot: those of a public
- * custom LLM, which shadows any built-in model with the same id, or those of a
+ * Providers a model is pinned to regardless of the snapshot: those of a custom
+ * LLM, which shadows any built-in model with the same id, or those of a
* Kilo-exclusive model. Undefined when the snapshot decides.
*/
export async function getModelInferenceProviderRestriction(
modelId: string
): Promise | undefined> {
return (
- (await getPublicCustomLlmInferenceProviders(modelId)) ??
+ (await getCustomLlmInferenceProviderRestriction(modelId)) ??
getKiloExclusiveInferenceProviders(modelId)
);
}
From 7cb4d15fcd5027054d000df79ce9f8463a0947d1 Mon Sep 17 00:00:00 2001
From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com>
Date: Wed, 21 Oct 2026 13:29:44 +0000
Subject: [PATCH 3/5] test(web): include the public description in the invalid
provider slug case
---
apps/web/src/routers/admin/custom-llm-router.test.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/routers/admin/custom-llm-router.test.ts b/apps/web/src/routers/admin/custom-llm-router.test.ts
index 869cc073750..867f28ed8aa 100644
--- a/apps/web/src/routers/admin/custom-llm-router.test.ts
+++ b/apps/web/src/routers/admin/custom-llm-router.test.ts
@@ -605,7 +605,10 @@ describe('adminCustomLlmRouter', () => {
await expect(
caller.admin.customLlm.upsert({
public_id: 'acme/public-model',
- definition: { ...publicDefinition, public: { inference_providers: ['Acme AI'] } },
+ definition: {
+ ...publicDefinition,
+ public: { inference_providers: ['Acme AI'], description: 'A public custom model' },
+ },
credentials,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
From 8e8b14e61fe4d0d24e80b17a85e741ac8a91c858 Mon Sep 17 00:00:00 2001
From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com>
Date: Wed, 21 Oct 2026 18:51:46 +0000
Subject: [PATCH 4/5] fix(ai-gateway): canonicalize model ids, keep custom LLM
protocols, and keep custom ids out of auto-free
---
.../routers/admin/auto-free-config-router.ts | 14 +++++-
apps/web/src/tests/openrouter-models.test.ts | 6 +++
.../auto-model/resolution.free.test.ts | 22 +++++++-
.../lib/ai-gateway/auto-model/resolution.ts | 11 +++-
.../lib/ai-gateway/handlers/llm-proxy.test.ts | 50 +++++++++++++++++++
.../src/lib/ai-gateway/handlers/llm-proxy.ts | 14 ++++--
.../ai-gateway/providers/openrouter/index.ts | 32 +++++++-----
7 files changed, 129 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/routers/admin/auto-free-config-router.ts b/apps/web/src/routers/admin/auto-free-config-router.ts
index 1e7f5dba776..985e8acd173 100644
--- a/apps/web/src/routers/admin/auto-free-config-router.ts
+++ b/apps/web/src/routers/admin/auto-free-config-router.ts
@@ -8,6 +8,7 @@ import { ai_gateway_config } from '@kilocode/db/schema';
import { AutoFreeConfigSchema } from '@kilocode/db/schema-types';
import { TRPCError } from '@trpc/server';
import { eq } from 'drizzle-orm';
+import { isCustomLlm } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import * as z from 'zod';
const SetAutoFreeConfigSchema = z.object({
@@ -25,11 +26,20 @@ export const adminAutoFreeConfigRouter = createTRPCRouter({
}),
set: adminProcedure.input(SetAutoFreeConfigSchema).mutation(async ({ input }) => {
- const ineligibleModelIds = input.config ? getIneligibleAutoFreeModelIds(input.config) : [];
+ const configuredModelIds = input.config?.models.map(({ model }) => model) ?? [];
+ const customLlmModelIds = (
+ await Promise.all(
+ configuredModelIds.map(async model => ((await isCustomLlm(model)) ? [model] : []))
+ )
+ ).flat();
+ const ineligibleModelIds = [
+ ...(input.config ? getIneligibleAutoFreeModelIds(input.config) : []),
+ ...customLlmModelIds,
+ ];
if (ineligibleModelIds.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
- message: `Not eligible for kilo-auto/free (must be a free model that uses the default AI SDK provider): ${ineligibleModelIds.join(', ')}`,
+ message: `Not eligible for kilo-auto/free (must be a free model that uses the default AI SDK provider and is not a custom LLM): ${ineligibleModelIds.join(', ')}`,
});
}
diff --git a/apps/web/src/tests/openrouter-models.test.ts b/apps/web/src/tests/openrouter-models.test.ts
index 47029ac298b..17e68e1634c 100644
--- a/apps/web/src/tests/openrouter-models.test.ts
+++ b/apps/web/src/tests/openrouter-models.test.ts
@@ -508,6 +508,7 @@ describe('GET /api/v1/models', () => {
new Map([
['acme/public-model', customLlm('acme/public-model', true)],
['acme/private-model', customLlm('acme/private-model', false)],
+ ['acme/claude-review', customLlm('acme/claude-review', true)],
[exclusiveModel.public_id, customLlm(exclusiveModel.public_id, true)],
])
);
@@ -526,6 +527,11 @@ describe('GET /api/v1/models', () => {
mayTrainOnYourPrompts: true,
});
expect(responseData.data.some(item => item.id === 'acme/private-model')).toBe(false);
+ // Routing serves a definition without opencode settings over Chat Completions,
+ // so the catalog must not guess a protocol from the id.
+ expect(responseData.data.find(item => item.id === 'acme/claude-review')?.opencode).toBe(
+ undefined
+ );
const sameIdModels = responseData.data.filter(item => item.id === exclusiveModel.public_id);
expect(sameIdModels).toHaveLength(1);
expect(sameIdModels[0].name).toBe(`Custom ${exclusiveModel.public_id}`);
diff --git a/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.free.test.ts b/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.free.test.ts
index 8e447b5ae78..014d25506c9 100644
--- a/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.free.test.ts
+++ b/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.free.test.ts
@@ -7,7 +7,14 @@ import {
glyph_cluster_stealth_free_model,
} from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import { OPENROUTER } from '@kilocode/web-shared/lib/ai-gateway/providers/definitions/openrouter';
-import { getConfiguredAutoFreeModels } from '@kilocode/web-shared/lib/ai-gateway/auto-model/auto-free-config';
+import {
+ AUTO_FREE_FALLBACK_MODEL,
+ getConfiguredAutoFreeModels,
+} from '@kilocode/web-shared/lib/ai-gateway/auto-model/auto-free-config';
+import {
+ deleteCustomLlmForTest,
+ insertCustomLlmForTest,
+} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
import type * as AutoFreeConfigModule from '@kilocode/web-shared/lib/ai-gateway/auto-model/auto-free-config';
import type * as ExclusiveModelsModule from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import type * as GatewayModelsCache from '@kilocode/web-shared/lib/ai-gateway/providers/gateway-models-cache';
@@ -107,6 +114,19 @@ describe('getAutoFreeCandidates', () => {
expect(await getAutoFreeCandidates('chat_completions')).toEqual(['test/present:free']);
});
+ it('skips a configured Kilo-exclusive model that a custom LLM now replaces', async () => {
+ await insertCustomLlmForTest(glyph_cluster_stealth_free_model.public_id);
+ try {
+ configure([glyph_cluster_stealth_free_model.public_id]);
+
+ expect(await getAutoFreeCandidates('chat_completions')).toEqual([
+ AUTO_FREE_FALLBACK_MODEL.model,
+ ]);
+ } finally {
+ await deleteCustomLlmForTest(glyph_cluster_stealth_free_model.public_id);
+ }
+ });
+
it('falls back to openrouter/free when nothing is configured or the config is invalid', async () => {
jest.mocked(getConfiguredAutoFreeModels).mockResolvedValue(null);
diff --git a/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.ts b/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.ts
index 8162f2cf11d..8d60338a2a6 100644
--- a/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.ts
+++ b/packages/web-shared/src/lib/ai-gateway/auto-model/resolution.ts
@@ -45,6 +45,7 @@ import {
isKiloExclusiveFreeModel,
} from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import { getOpenRouterModelsFromDatabase } from '@kilocode/web-shared/lib/ai-gateway/providers/gateway-models-cache';
+import { lookupCustomLlm } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import {
getOrganizationAutoRoute,
isOrganizationAutoTargetModel,
@@ -88,9 +89,15 @@ async function getEligibleAutoFreeModels(
const configuredModels = await getConfiguredAutoFreeModels();
if (!configuredModels) return [AUTO_FREE_FALLBACK_MODEL];
- const openRouterModels = await getOpenRouterModelsFromDatabase();
- const eligibleModels = configuredModels.filter(({ model }) => {
+ const [openRouterModels, customLlmLookups] = await Promise.all([
+ getOpenRouterModelsFromDatabase(),
+ Promise.all(configuredModels.map(({ model }) => lookupCustomLlm(model))),
+ ]);
+ const eligibleModels = configuredModels.filter(({ model }, index) => {
if (!isAutoFreeEligibleModelId(model)) return false;
+ // A custom LLM may replace a configured Kilo-exclusive id; anonymous
+ // callers cannot use it, so it is never a candidate, even while unknown.
+ if (customLlmLookups[index]?.kind !== 'none') return false;
if (isKiloExclusiveFreeModel(model)) {
const kiloModel = findKiloExclusiveModel(model);
return (
diff --git a/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.test.ts b/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.test.ts
index 17af007ecd2..11be0e54e2f 100644
--- a/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.test.ts
+++ b/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.test.ts
@@ -2998,4 +2998,54 @@ describe('custom LLM requests', () => {
);
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
});
+
+ function setOrganizationAuth() {
+ mockedGetUserFromAuth.mockResolvedValue({
+ user: { id: 'user-123', google_user_email: 'test@example.com', microdollars_used: 0 } as User,
+ authFailedResponse: null,
+ organizationId: 'org-123',
+ });
+ mockedGetBalanceAndOrgSettings.mockResolvedValue({
+ balance: 1000,
+ settings: { data_collection: 'allow' },
+ plan: 'enterprise',
+ });
+ }
+
+ it('checks organization policy against the trimmed model id', async () => {
+ setOrganizationAuth();
+ mockedGetEffectiveModelDecision.mockResolvedValueOnce({
+ allowed: false,
+ denialSource: 'organization_model',
+ });
+ mockCustomLlm(customLlm('acme/public-model', true));
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody(' acme/public-model ')) as never
+ );
+
+ expect(response.status).toBe(404);
+ expect(mockedGetEffectiveModelDecision).toHaveBeenCalledWith(
+ expect.anything(),
+ 'acme/public-model'
+ );
+ expect(mockedUpstreamRequest).not.toHaveBeenCalled();
+ });
+
+ it('keeps the request provider exclusions when organization policy replaces the provider config', async () => {
+ setOrganizationAuth();
+ mockedGetEffectiveModelDecision.mockResolvedValueOnce({
+ allowed: true,
+ eligibleProviderRoutes: new Set(['acme']),
+ });
+ mockCustomLlm(customLlm('acme/public-model', true));
+
+ const response = await handleLlmProxyRequest(
+ makeRequest({ ...makeBody('acme/public-model'), provider: { ignore: ['acme'] } }) as never
+ );
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ error_type: 'provider_not_allowed' });
+ expect(mockedUpstreamRequest).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.ts b/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.ts
index 5a2c4c8b3e2..8f1e354e576 100644
--- a/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.ts
+++ b/packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.ts
@@ -312,7 +312,9 @@ export async function handleLlmProxyRequest(
}
}
- const requestedModel = requestBodyParsed.body.model.trim();
+ // Canonicalize once so routing, policy, and the upstream request all see the same id.
+ requestBodyParsed.body.model = requestBodyParsed.body.model.trim();
+ const requestedModel = requestBodyParsed.body.model;
const requestedModelLowerCased = requestedModel.toLowerCase();
const feature = validateFeatureHeader(
@@ -999,12 +1001,18 @@ export async function handleLlmProxyRequest(
return dataCollectionRequiredResponse();
}
+ // Organization policy replaces the request's provider object, so the caller's
+ // own only/ignore lists are checked separately from the effective config.
const providerNotAllowedError = customLlm
? customLlm.definition.public
- ? checkInferenceProvidersAllowed(
+ ? (checkInferenceProvidersAllowed(
+ customLlm.definition.public.inference_providers,
+ requestProvider
+ ) ??
+ checkInferenceProvidersAllowed(
customLlm.definition.public.inference_providers,
requestBodyParsed.body.provider
- )
+ ))
: null
: checkExclusiveModelProviderAllowed(
effectiveModelIdLowerCased,
diff --git a/packages/web-shared/src/lib/ai-gateway/providers/openrouter/index.ts b/packages/web-shared/src/lib/ai-gateway/providers/openrouter/index.ts
index 5103527e286..e37ae36914f 100644
--- a/packages/web-shared/src/lib/ai-gateway/providers/openrouter/index.ts
+++ b/packages/web-shared/src/lib/ai-gateway/providers/openrouter/index.ts
@@ -195,9 +195,11 @@ async function enhancedModelList(models: OpenRouterModel[]) {
.filter(m => !unavailableModelIds.has(m.public_id) && !isShadowedByCustomLlm(m.public_id))
.map(model => convertFromKiloExclusiveModel(model))
)
- .concat(publicCustomLlmModels)
.concat(autoModels)
- .map(applyCustomPricingToModel);
+ .map(applyCustomPricingToModel)
+ // Public custom LLMs are complete as defined; id-based pricing, PDF, and
+ // protocol guesses below would describe a model routing does not serve.
+ .concat(publicCustomLlmModels);
const exclusiveById = new Map(
kiloExclusiveModels
.filter(entry => !isShadowedByCustomLlm(entry.public_id))
@@ -218,11 +220,15 @@ async function enhancedModelList(models: OpenRouterModel[]) {
const inferenceProviderIds = [
...new Set([...providerIds].map(id => normalizeInferenceProviderId(id))),
];
+ const isPublicCustomLlmModel = publicCustomLlmProviders.has(model.id);
const addPdf =
- isPdfSupportingModel(model.id) && !model.architecture.input_modalities.includes('pdf');
- const description = isFreeNemotronModel(model.id)
- ? model.description + '\n\n**Terms of service** ' + NVIDIA_TRIAL_TOS
- : model.description;
+ !isPublicCustomLlmModel &&
+ isPdfSupportingModel(model.id) &&
+ !model.architecture.input_modalities.includes('pdf');
+ const description =
+ !isPublicCustomLlmModel && isFreeNemotronModel(model.id)
+ ? model.description + '\n\n**Terms of service** ' + NVIDIA_TRIAL_TOS
+ : model.description;
const isFree = await isFreeModel(model.id);
return {
...model,
@@ -233,12 +239,14 @@ async function enhancedModelList(models: OpenRouterModel[]) {
isFree: model.isFree ?? isFree,
mayTrainOnYourPrompts:
model.mayTrainOnYourPrompts ?? (isFree || dataCollectionRequiredModelIds.has(model.id)),
- opencode:
- model.opencode ??
- (await getGatewayOpenCodeSettings(
- model.id,
- model.supported_parameters?.includes('reasoning') ?? false
- )),
+ // A custom LLM's protocol comes only from its definition, as in routing.
+ opencode: isPublicCustomLlmModel
+ ? model.opencode
+ : (model.opencode ??
+ (await getGatewayOpenCodeSettings(
+ model.id,
+ model.supported_parameters?.includes('reasoning') ?? false
+ ))),
architecture: addPdf
? {
...model.architecture,
From acbeb1128e35fd5c6e54ee3832f8cf44efa3303d Mon Sep 17 00:00:00 2001
From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com>
Date: Wed, 21 Oct 2026 18:54:19 +0000
Subject: [PATCH 5/5] test(web): reject custom LLM ids in the auto-free config
---
.../admin/auto-free-config-router.test.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/apps/web/src/routers/admin/auto-free-config-router.test.ts b/apps/web/src/routers/admin/auto-free-config-router.test.ts
index 4297f135de2..8bfb2b15586 100644
--- a/apps/web/src/routers/admin/auto-free-config-router.test.ts
+++ b/apps/web/src/routers/admin/auto-free-config-router.test.ts
@@ -5,6 +5,10 @@ import { AutoFreeConfigSchema, type AutoFreeConfig } from '@kilocode/db/schema-t
import { AUTO_FREE_FALLBACK_CONFIG } from '@kilocode/web-shared/lib/ai-gateway/auto-model/auto-free-config';
import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper';
import { createCallerForUser } from '@/routers/test-utils';
+import {
+ deleteCustomLlmForTest,
+ insertCustomLlmForTest,
+} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
let admin: User;
let nonAdmin: User;
@@ -147,4 +151,19 @@ describe('adminAutoFreeConfigRouter', () => {
expect(await db.select().from(ai_gateway_config)).toEqual([]);
}
);
+
+ it('rejects a free model id that a custom LLM now owns', async () => {
+ const [{ model }] = config.models;
+ await insertCustomLlmForTest(model);
+ try {
+ const caller = await createCallerForUser(admin.id);
+
+ await expect(caller.admin.autoFreeConfig.set({ config })).rejects.toMatchObject({
+ code: 'BAD_REQUEST',
+ message: expect.stringContaining(model),
+ });
+ } finally {
+ await deleteCustomLlmForTest(model);
+ }
+ });
});