diff --git a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx
index fe23aa97cdb..76dfdf26e49 100644
--- a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx
+++ b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useCallback, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -21,6 +21,16 @@ import {
TableRow,
} from '@/components/ui/table';
import { InlineDeleteConfirmation } from '@/components/ui/inline-delete-confirmation';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import {
useCustomLlms,
useCopyCustomLlm,
@@ -36,7 +46,8 @@ import type { CustomLlmCredentials, CustomLlmDefinition } from '@kilocode/db/sch
import { deepStrict } from '@/lib/zod/deep-strict';
import { formatZodError } from '@/lib/zod/format-zod-error';
import { toast } from 'sonner';
-import { Copy as CopyIcon, Plus, Pencil } from 'lucide-react';
+import { Copy as CopyIcon, Globe, Lock, Plus, Pencil } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
import Editor from '@monaco-editor/react';
const StrictCustomLlmDefinitionSchema = deepStrict(CustomLlmDefinitionSchema);
@@ -46,6 +57,7 @@ type EditorState = {
open: boolean;
mode: 'create' | 'edit';
publicId: string;
+ wasPublic: boolean;
credentialsJson: string;
definitionJson: string;
validationError: string | null;
@@ -53,6 +65,7 @@ type EditorState = {
type CopyState = {
sourcePublicId: string;
+ sourceIsPublic: boolean;
publicId: string;
displayName: string;
internalId: string;
@@ -77,10 +90,33 @@ const INITIAL_CREDENTIALS: CustomLlmCredentials = {
api_key: '',
};
+type UpsertInput = {
+ public_id: string;
+ definition: CustomLlmDefinition;
+ credentials: CustomLlmCredentials | undefined;
+};
+
+type CopyInput = {
+ source_public_id: string;
+ public_id: string;
+ display_name: string;
+ internal_id: string | undefined;
+};
+
+/** A save that needs confirmation because it affects users beyond the editor. */
+type PendingChange = (
+ | { kind: 'upsert'; input: UpsertInput }
+ | { kind: 'copy'; input: CopyInput }
+) & {
+ makesPublic: boolean;
+ replacesKiloExclusiveModel: boolean;
+};
+
const initialEditorState: EditorState = {
open: false,
mode: 'create',
publicId: '',
+ wasPublic: false,
credentialsJson: JSON.stringify(INITIAL_CREDENTIALS, null, 2),
definitionJson: JSON.stringify(INITIAL_DEFINITION, null, 2),
validationError: null,
@@ -93,12 +129,18 @@ export function CustomLlmsContent() {
const deleteMutation = useDeleteCustomLlm();
const [editor, setEditor] = useState(initialEditorState);
const [copy, setCopy] = useState(null);
+ const [pendingChange, setPendingChange] = useState(null);
+ const kiloExclusiveModelIds = useMemo(
+ () => new Set(data?.kiloExclusiveModelIds),
+ [data?.kiloExclusiveModelIds]
+ );
const openCreate = useCallback(() => {
setEditor({
open: true,
mode: 'create',
publicId: '',
+ wasPublic: false,
credentialsJson: JSON.stringify(INITIAL_CREDENTIALS, null, 2),
definitionJson: JSON.stringify(INITIAL_DEFINITION, null, 2),
validationError: null,
@@ -110,6 +152,7 @@ export function CustomLlmsContent() {
open: true,
mode: 'edit',
publicId,
+ wasPublic: definition.public !== undefined,
credentialsJson: '',
definitionJson: JSON.stringify(definition, null, 2),
validationError: null,
@@ -120,23 +163,54 @@ export function CustomLlmsContent() {
setEditor(initialEditorState);
}, []);
- const openCopy = useCallback(
- (sourcePublicId: string, sourceDisplayName: string, sourceInternalId: string | undefined) => {
- setCopy({
- sourcePublicId,
- publicId: sourcePublicId,
- displayName: sourceDisplayName,
- internalId: sourceInternalId ?? '',
- validationError: null,
- });
- },
- []
- );
+ const openCopy = useCallback((sourcePublicId: string, source: CustomLlmDefinition) => {
+ setCopy({
+ sourcePublicId,
+ sourceIsPublic: source.public !== undefined,
+ publicId: sourcePublicId,
+ displayName: source.display_name,
+ internalId: source.internal_id ?? '',
+ validationError: null,
+ });
+ }, []);
const closeCopy = useCallback(() => {
setCopy(null);
}, []);
+ const submitCopy = useCallback(
+ async (input: CopyInput) => {
+ try {
+ await copyMutation.mutateAsync(input);
+ toast.success('Custom LLM copied');
+ closeCopy();
+ } catch (error) {
+ setCopy(prev =>
+ prev
+ ? {
+ ...prev,
+ validationError: { field: null, message: formatZodError(error) },
+ }
+ : prev
+ );
+ }
+ },
+ [copyMutation, closeCopy]
+ );
+
+ const submitUpsert = useCallback(
+ async (input: UpsertInput) => {
+ try {
+ await upsertMutation.mutateAsync(input);
+ toast.success(editor.mode === 'create' ? 'Custom LLM created' : 'Custom LLM updated');
+ closeEditor();
+ } catch (error) {
+ toast.error(formatZodError(error));
+ }
+ },
+ [editor.mode, upsertMutation, closeEditor]
+ );
+
const handleCopy = useCallback(async () => {
if (!copy) return;
@@ -181,26 +255,20 @@ export function CustomLlmsContent() {
return;
}
- try {
- await copyMutation.mutateAsync({
- source_public_id: copy.sourcePublicId,
- public_id: publicId,
- display_name: displayName,
- internal_id: internalId || undefined,
- });
- toast.success('Custom LLM copied');
- closeCopy();
- } catch (error) {
- setCopy(prev =>
- prev
- ? {
- ...prev,
- validationError: { field: null, message: formatZodError(error) },
- }
- : prev
- );
+ const input: CopyInput = {
+ source_public_id: copy.sourcePublicId,
+ public_id: publicId,
+ display_name: displayName,
+ internal_id: internalId || undefined,
+ };
+ const makesPublic = copy.sourceIsPublic;
+ const replacesKiloExclusiveModel = kiloExclusiveModelIds.has(publicId);
+ if (makesPublic || replacesKiloExclusiveModel) {
+ setPendingChange({ kind: 'copy', input, makesPublic, replacesKiloExclusiveModel });
+ return;
}
- }, [copy, copyMutation, closeCopy]);
+ await submitCopy(input);
+ }, [copy, submitCopy, kiloExclusiveModelIds]);
const handleSave = useCallback(async () => {
const trimmedPublicId = editor.publicId.trim();
@@ -248,7 +316,11 @@ export function CustomLlmsContent() {
return;
}
- const defResult = StrictCustomLlmDefinitionSchema.safeParse(parsedDefinition);
+ // The strict copy rejects unknown keys but drops cross-field rules, so check both.
+ const strictDefResult = StrictCustomLlmDefinitionSchema.safeParse(parsedDefinition);
+ const defResult = strictDefResult.success
+ ? CustomLlmDefinitionSchema.safeParse(parsedDefinition)
+ : strictDefResult;
if (!defResult.success) {
setEditor(prev => ({ ...prev, validationError: formatZodError(defResult.error) }));
return;
@@ -262,18 +334,27 @@ export function CustomLlmsContent() {
return;
}
- try {
- await upsertMutation.mutateAsync({
- public_id: trimmedPublicId,
- definition: defResult.data,
- credentials: parsedCredentials,
- });
- toast.success(editor.mode === 'create' ? 'Custom LLM created' : 'Custom LLM updated');
- closeEditor();
- } catch (error) {
- toast.error(formatZodError(error));
+ const input: UpsertInput = {
+ public_id: trimmedPublicId,
+ definition: defResult.data,
+ credentials: parsedCredentials,
+ };
+ const makesPublic = input.definition.public !== undefined && !editor.wasPublic;
+ const replacesKiloExclusiveModel =
+ editor.mode === 'create' && kiloExclusiveModelIds.has(trimmedPublicId);
+ if (makesPublic || replacesKiloExclusiveModel) {
+ setPendingChange({ kind: 'upsert', input, makesPublic, replacesKiloExclusiveModel });
+ return;
}
- }, [editor, upsertMutation, closeEditor]);
+ await submitUpsert(input);
+ }, [editor, submitUpsert, kiloExclusiveModelIds]);
+
+ const confirmPendingChange = useCallback(async () => {
+ const change = pendingChange;
+ setPendingChange(null);
+ if (change?.kind === 'upsert') await submitUpsert(change.input);
+ if (change?.kind === 'copy') await submitCopy(change.input);
+ }, [pendingChange, submitUpsert, submitCopy]);
const handleDelete = useCallback(
async (publicId: string) => {
@@ -312,6 +393,7 @@ export function CustomLlmsContent() {
Public ID
Display Name
+ Access
Internal ID
Actions
@@ -319,7 +401,7 @@ export function CustomLlmsContent() {
{data?.items.length === 0 && (
-
+
No custom LLMs defined yet.
@@ -328,6 +410,24 @@ export function CustomLlmsContent() {
{item.public_id}
{item.definition.display_name}
+
+
+ {item.definition.public ? (
+
+
+ Public
+
+ ) : (
+
+
+ Private
+
+ )}
+ {kiloExclusiveModelIds.has(item.public_id) && (
+ Replaces Kilo-exclusive
+ )}
+
+
{item.definition.internal_id ?? 'Not set'}
@@ -345,13 +445,7 @@ export function CustomLlmsContent() {
- openCopy(
- item.public_id,
- item.definition.display_name,
- item.definition.internal_id
- )
- }
+ onClick={() => openCopy(item.public_id, item.definition)}
aria-label={`Copy ${item.public_id}`}
title="Copy custom LLM"
>
@@ -504,9 +598,22 @@ export function CustomLlmsContent() {
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"],
+ "description": "…" {'}'}
+
+ . Organization provider allow lists apply to these providers. Unknown providers
+ appear in the provider list after the next provider sync. Without{' '}
+ public, organization_ids, or group_ids, the
+ model is stored but nobody can use it. Saved changes reach every server within a
+ minute.
@@ -528,6 +635,51 @@ export function CustomLlmsContent() {
+ {
+ if (!open) setPendingChange(null);
+ }}
+ >
+
+
+
+ {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?.replacesKiloExclusiveModel && (
+
+ {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.
+
+ )}
+
+ Test this definition with a local web app before saving it. A broken definition
+ fails requests for everyone who can use it.
+
+
+
+
+
+ Cancel
+
+ {pendingChange?.makesPublic ? 'Make public' : 'Replace model'}
+
+
+
+
+
{
diff --git a/apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.test.ts b/apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.test.ts
new file mode 100644
index 00000000000..68a65ffa933
--- /dev/null
+++ b/apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from '@jest/globals';
+import type { CustomLlm } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import { buildPublicCustomLlmSnapshotModels } from './public-custom-llm-snapshot';
+
+const baseDefinition = {
+ display_name: 'Acme Model',
+ context_length: 64_000,
+ max_completion_tokens: 8_000,
+ base_url: 'https://upstream.example.com/v1',
+};
+
+const publicCustomLlm: CustomLlm = {
+ public_id: 'acme/model',
+ encrypted_api_key: null,
+ definition: {
+ ...baseDefinition,
+ supports_image_input: true,
+ public: {
+ inference_providers: ['acme', 'together', 'acme'],
+ description: 'A free Acme model',
+ },
+ },
+};
+
+const privateCustomLlm: CustomLlm = {
+ public_id: 'acme/private',
+ encrypted_api_key: null,
+ definition: { ...baseDefinition, organization_ids: [] },
+};
+
+describe('buildPublicCustomLlmSnapshotModels', () => {
+ test('lists a public custom LLM once under each declared provider as a free model', () => {
+ const models = buildPublicCustomLlmSnapshotModels(
+ [publicCustomLlm, privateCustomLlm],
+ '2026-10-08T00:00:00.000Z'
+ );
+
+ expect(models.map(entry => entry.provider)).toEqual([
+ { slug: 'acme', name: 'ACME', training: true, retainsPrompts: true },
+ { slug: 'together', name: 'TOGETHER', training: true, retainsPrompts: true },
+ ]);
+ expect(models[0].model).toEqual({
+ slug: 'acme/model',
+ name: 'Acme Model',
+ author: 'Other',
+ description: 'A free Acme model',
+ context_length: 64_000,
+ input_modalities: ['text', 'image'],
+ output_modalities: ['text'],
+ group: 'other',
+ updated_at: '2026-10-08T00:00:00.000Z',
+ endpoint: {
+ provider_display_name: 'Other',
+ is_free: true,
+ pricing: { prompt: '0', completion: '0' },
+ data_policy: { training: true, retainsPrompts: true },
+ },
+ });
+ });
+});
diff --git a/apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.ts b/apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.ts
new file mode 100644
index 00000000000..0e439cc3538
--- /dev/null
+++ b/apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.ts
@@ -0,0 +1,47 @@
+import {
+ isPublicCustomLlm,
+ type CustomLlm,
+} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import { normalizeModelId } from '@kilocode/web-shared/lib/ai-gateway/model-utils';
+import type { OpenRouterModel } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/openrouter-types';
+
+/** A model added to the snapshot under a provider that may not exist upstream. */
+export type SnapshotExtraModel = {
+ model: OpenRouterModel;
+ provider: { slug: string; name: string; training: boolean; retainsPrompts: boolean };
+};
+
+/**
+ * Lists each public custom LLM under every inference provider it declares.
+ * Public custom LLMs are free, so like free Kilo-exclusive models they are
+ * assumed to train on and retain prompts.
+ */
+export function buildPublicCustomLlmSnapshotModels(
+ customLlms: readonly CustomLlm[],
+ updatedAt: string
+): SnapshotExtraModel[] {
+ return customLlms.flatMap(({ public_id, definition }) => {
+ if (!isPublicCustomLlm(definition)) return [];
+ const model: OpenRouterModel = {
+ slug: normalizeModelId(public_id),
+ name: definition.display_name,
+ author: 'Other',
+ description: definition.public.description,
+ context_length: definition.context_length,
+ input_modalities: definition.supports_image_input ? ['text', 'image'] : ['text'],
+ output_modalities: ['text'],
+ group: 'other',
+ updated_at: updatedAt,
+ endpoint: {
+ provider_display_name: 'Other',
+ is_free: true,
+ pricing: { prompt: '0', completion: '0' },
+ data_policy: { training: true, retainsPrompts: true },
+ },
+ };
+ return [...new Set(definition.public.inference_providers)].map(slug => ({
+ model,
+ provider: { slug, name: slug.toUpperCase(), training: true, retainsPrompts: true },
+ }));
+ });
+}
diff --git a/apps/web/src/lib/providers/openrouter/sync-providers.ts b/apps/web/src/lib/providers/openrouter/sync-providers.ts
index 99687829456..c37233bbffd 100644
--- a/apps/web/src/lib/providers/openrouter/sync-providers.ts
+++ b/apps/web/src/lib/providers/openrouter/sync-providers.ts
@@ -42,6 +42,14 @@ import { getVercelModelIdOverrides } from '@kilocode/web-shared/lib/ai-gateway/p
import { injectSupportedFimModels } from '@kilocode/web-shared/lib/ai-gateway/supported-fim-models';
import { injectVirtualModels } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/virtual-models';
import type { OpenRouterModel as CatalogModel } from '@kilocode/web-shared/lib/organizations/organization-types';
+import {
+ fetchCustomLlmsFromDatabase,
+ type CustomLlm,
+} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import {
+ buildPublicCustomLlmSnapshotModels,
+ type SnapshotExtraModel,
+} from '@/lib/providers/openrouter/public-custom-llm-snapshot';
/**
* Advisory lock key hashed from a stable identifier. Serializes concurrent
@@ -153,8 +161,9 @@ async function syncProviders(params: {
openRouterModels: Record;
openRouterCatalogModels: CatalogModel[];
vercelModels: Record;
+ customLlms: CustomLlm[];
}) {
- const { providers, openRouterModels, openRouterCatalogModels, vercelModels } = params;
+ const { providers, openRouterModels, openRouterCatalogModels, vercelModels, customLlms } = params;
if (providers.length === 0) {
throw new Error('No providers found in OpenRouter response');
}
@@ -193,7 +202,13 @@ async function syncProviders(params: {
injectExtraProviderModels(vercelModels, providerModelData, vercelModelIdOverrides);
injectVercelOnlyProviders(vercelModels, providerModelData, vercelModelIdOverrides);
- const mappedExtraModels = kiloExclusiveModels
+ // A custom LLM takes precedence over a Kilo-exclusive model with the same id.
+ const customLlmIds = new Set(customLlms.map(customLlm => customLlm.public_id.toLowerCase()));
+ const unshadowedKiloExclusiveModels = kiloExclusiveModels.filter(
+ kfm => !customLlmIds.has(kfm.public_id.toLowerCase())
+ );
+
+ const mappedKiloExclusiveModels: SnapshotExtraModel[] = unshadowedKiloExclusiveModels
.flatMap(kfm =>
unavailableModelIds.has(kfm.public_id)
? []
@@ -228,6 +243,9 @@ async function syncProviders(params: {
provider: inferenceProvider,
};
});
+ const mappedExtraModels = mappedKiloExclusiveModels.concat(
+ buildPublicCustomLlmSnapshotModels(customLlms, new Date().toISOString())
+ );
for (const extraModel of mappedExtraModels) {
const providerData = providerModelData.find(
@@ -246,7 +264,7 @@ async function syncProviders(params: {
applyFreeEndpointDataPolicy({
providerModelData,
openRouterFreeEndpoints,
- kiloExclusiveModels,
+ kiloExclusiveModels: unshadowedKiloExclusiveModels,
unavailableModelIds,
});
@@ -294,7 +312,7 @@ async function syncProviders(params: {
const allProviders = [...normalizedProviders];
- // Auto-detect providers referenced by extra models that aren't already present
+ // Synthesize providers referenced by extra models that aren't already present
const missingProviders = new Map(
mappedExtraModels
.map(m => m.provider)
@@ -421,6 +439,7 @@ export async function syncAndStoreProviders() {
openRouterModels: openrouter_data,
openRouterCatalogModels: openRouterCatalog.data,
vercelModels: vercel_data,
+ customLlms: await fetchCustomLlmsFromDatabase(),
});
if (providers.total_providers < 10) {
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);
+ }
+ });
});
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/routers/admin/custom-llm-router.test.ts b/apps/web/src/routers/admin/custom-llm-router.test.ts
index 885b4117466..867f28ed8aa 100644
--- a/apps/web/src/routers/admin/custom-llm-router.test.ts
+++ b/apps/web/src/routers/admin/custom-llm-router.test.ts
@@ -31,6 +31,15 @@ const validDefinition: CustomLlmDefinition = {
group_ids: ['00000000-0000-4000-8000-000000000123'],
};
+const publicDefinition: CustomLlmDefinition = {
+ internal_id: 'upstream-model',
+ display_name: 'Public model',
+ context_length: 128000,
+ max_completion_tokens: 4096,
+ base_url: 'https://upstream.example.com/v1',
+ public: { inference_providers: ['acme'], description: 'A public custom model' },
+};
+
beforeEach(async () => {
jest
.mocked(getOpenRouterModelsMetadataFromDatabase)
@@ -262,6 +271,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', () => {
@@ -459,23 +478,23 @@ describe('adminCustomLlmRouter', () => {
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining(message) });
});
- it('rejects a Kilo-exclusive model id', async () => {
+ it('accepts a Kilo-exclusive model id even when OpenRouter lists it', async () => {
const exclusiveModelId = kiloExclusiveModels.find(
model => !model.public_id.includes(':')
)?.public_id;
if (!exclusiveModelId) throw new Error('Expected a Kilo-exclusive id without a variant');
+ jest
+ .mocked(getOpenRouterModelsMetadataFromDatabase)
+ .mockResolvedValue({ [exclusiveModelId]: {} as StoredModel });
const caller = await createCallerForUser(admin.id);
- await expect(
- caller.admin.customLlm.upsert({
- public_id: exclusiveModelId,
- definition: validDefinition,
- credentials,
- })
- ).rejects.toMatchObject({
- code: 'BAD_REQUEST',
- message: expect.stringContaining('Kilo-exclusive'),
+ const result = await caller.admin.customLlm.upsert({
+ public_id: exclusiveModelId,
+ definition: validDefinition,
+ credentials,
});
+
+ expect(result.public_id).toBe(exclusiveModelId);
});
it('rejects an id that differs only in case from an existing custom LLM', async () => {
@@ -529,6 +548,73 @@ describe('adminCustomLlmRouter', () => {
});
});
+ describe('public definitions', () => {
+ const credentials = { type: 'api_key', api_key: 'sk-secret' } as const;
+
+ it('creates a public custom LLM', async () => {
+ const caller = await createCallerForUser(admin.id);
+
+ const result = await caller.admin.customLlm.upsert({
+ public_id: 'acme/public-model',
+ definition: publicDefinition,
+ credentials,
+ });
+
+ expect(result.definition.public).toEqual({
+ inference_providers: ['acme'],
+ description: 'A public custom model',
+ });
+ });
+
+ it.each([
+ ['organization_ids', { organization_ids: [] }],
+ ['group_ids', { group_ids: ['00000000-0000-4000-8000-000000000123'] }],
+ ['description', { public: { inference_providers: ['acme'], description: ' ' } }],
+ ])('rejects a public definition with an invalid %s', async (_field, overrides) => {
+ const caller = await createCallerForUser(admin.id);
+
+ await expect(
+ caller.admin.customLlm.upsert({
+ public_id: 'acme/invalid-public-model',
+ definition: { ...publicDefinition, ...overrides },
+ credentials,
+ })
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+
+ it('stores a private definition that nobody is granted yet', async () => {
+ const caller = await createCallerForUser(admin.id);
+ const {
+ organization_ids: _organizationIds,
+ group_ids: _groupIds,
+ ...definition
+ } = validDefinition;
+
+ const result = await caller.admin.customLlm.upsert({
+ public_id: 'acme/unassigned-model',
+ definition,
+ credentials,
+ });
+
+ expect(result.definition.organization_ids).toBeUndefined();
+ });
+
+ it('rejects an inference provider that is not a lowercase slug', async () => {
+ const caller = await createCallerForUser(admin.id);
+
+ await expect(
+ caller.admin.customLlm.upsert({
+ public_id: 'acme/public-model',
+ definition: {
+ ...publicDefinition,
+ public: { inference_providers: ['Acme AI'], description: 'A public custom model' },
+ },
+ credentials,
+ })
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+ });
+
describe('delete', () => {
it('deletes a custom LLM by public_id', async () => {
const caller = await createCallerForUser(admin.id);
diff --git a/apps/web/src/routers/admin/custom-llm-router.ts b/apps/web/src/routers/admin/custom-llm-router.ts
index 91af2153e1a..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');
@@ -24,8 +27,9 @@ function badRequest(message: string): never {
/**
* Routing resolves custom LLMs before any other upstream, so a new id must not
- * hide an OpenRouter, Kilo-exclusive or direct BYOK model. Existing rows keep
- * their id, so this only runs when an id is claimed.
+ * hide an OpenRouter or direct BYOK model. Reusing a Kilo-exclusive id is
+ * allowed on purpose: the custom LLM then replaces that model. Existing rows
+ * keep their id, so this only runs when an id is claimed.
*/
async function assertPublicIdIsClaimable(publicId: string) {
const parsedPublicId = CustomLlmPublicIdSchema.safeParse(publicId);
@@ -39,10 +43,10 @@ async function assertPublicIdIsClaimable(publicId: string) {
if (directByokProvider) {
badRequest(`public_id must not use the direct BYOK prefix "${directByokProvider}/"`);
}
- if (isKiloExclusiveModel(modelId)) {
- badRequest(`public_id "${modelId}" is a Kilo-exclusive model`);
- }
- if (Object.hasOwn(await getOpenRouterModelsMetadataFromDatabase(), modelId)) {
+ if (
+ !isKiloExclusiveModel(modelId) &&
+ Object.hasOwn(await getOpenRouterModelsMetadataFromDatabase(), modelId)
+ ) {
badRequest(`public_id "${modelId}" already exists in the OpenRouter model list`);
}
// Custom LLM lookups ignore case, so a case variant would shadow the other row.
@@ -86,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/apps/web/src/tests/openrouter-models.test.ts b/apps/web/src/tests/openrouter-models.test.ts
index fcf070558a4..17e68e1634c 100644
--- a/apps/web/src/tests/openrouter-models.test.ts
+++ b/apps/web/src/tests/openrouter-models.test.ts
@@ -35,6 +35,11 @@ import { GET as statsGET } from '@/app/api/models/stats/route';
import { GET as statGET } from '@/app/api/models/stats/[slug]/route';
import type * as GatewayModelsCache from '@kilocode/web-shared/lib/ai-gateway/providers/gateway-models-cache';
import type * as Byok from '@kilocode/web-shared/lib/ai-gateway/byok';
+import type * as CustomLlmCatalog from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import {
+ getCustomLlmsById,
+ type CustomLlm,
+} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import { getTerminalBenchSummaries } from '@kilocode/web-shared/lib/model-stats/terminal-bench';
import {
kiloExclusiveModels,
@@ -147,6 +152,9 @@ jest.mock('@kilocode/web-shared/lib/ai-gateway/providers/direct-byok', () => {
});
jest.mock('@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog', () => ({
+ ...jest.requireActual(
+ '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog'
+ ),
getCustomLlmsById: jest.fn(async () => new Map()),
requireCustomLlmsById: jest.fn(async () => new Map()),
isCustomLlm: jest.fn(async () => false),
@@ -249,6 +257,7 @@ beforeEach(() => {
.mockImplementation(realDirectByok.getDirectByokModelsForUser);
jest.mocked(getUserByokProviderIds).mockReset().mockResolvedValue([]);
jest.mocked(getAvailableModelsForOrganization).mockReset().mockResolvedValue(null);
+ jest.mocked(getCustomLlmsById).mockReset().mockResolvedValue(new Map());
invalidateModelStatsCache();
mockRows.mockReset().mockResolvedValue(new Map());
jest.spyOn(Date, 'now').mockReturnValue(Date.parse(enkryptBenchmark.ingestedAt));
@@ -479,6 +488,55 @@ describe('GET /api/v1/models', () => {
expect(responseData.data.some(item => item.id === 'byok/openai/model')).toBe(false);
});
+ test('lists public custom LLMs as free and lets them replace a Kilo-exclusive model', async () => {
+ mockAuth = { user: null, organizationId: null };
+ const exclusiveModel = kiloExclusiveModels[0];
+ const customLlm = (public_id: string, isPublic: boolean): CustomLlm => ({
+ public_id,
+ encrypted_api_key: null,
+ definition: {
+ display_name: `Custom ${public_id}`,
+ context_length: 1000,
+ max_completion_tokens: 100,
+ base_url: 'https://upstream.example.com/v1',
+ ...(isPublic
+ ? { public: { inference_providers: ['acme'], description: 'Public custom model' } }
+ : { organization_ids: [] }),
+ },
+ });
+ jest.mocked(getCustomLlmsById).mockResolvedValue(
+ 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)],
+ ])
+ );
+ global.fetch = jest
+ .fn, Parameters>()
+ .mockResolvedValue(createMockResponse({ jsonData: mockOpenRouterModels }));
+
+ const response = await GET(createTestRequest('/api/openrouter/models'));
+ const responseData = OpenRouterModelsResponseSchema.parse(await response.json());
+
+ expect(response.status).toBe(200);
+ expect(responseData.data.find(item => item.id === 'acme/public-model')).toMatchObject({
+ name: 'Custom acme/public-model',
+ description: 'Public custom model',
+ isFree: true,
+ 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}`);
+ });
+
test('omits the Enkrypt field when no score is available', async () => {
global.fetch = jest
.fn, Parameters>()
diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts
index 494025237f4..703bafdbe92 100644
--- a/packages/db/src/schema-types.ts
+++ b/packages/db/src/schema-types.ts
@@ -2315,16 +2315,6 @@ export const CustomLlmCredentialsSchema = z.discriminatedUnion('type', [
export type CustomLlmCredentials = z.infer;
-export const CustomLlmDefinitionSchema = z.object({
- ...CustomLlmMetadataSchema.shape,
- ...CustomLlmApiConfigSchema.shape,
- display_name: z.string(),
- organization_ids: z.array(z.uuid()),
- group_ids: z.array(z.uuid()).optional(),
-});
-
-export type CustomLlmDefinition = z.infer;
-
export const GatewayModelIdSchema = z
.string()
.trim()
@@ -2341,6 +2331,51 @@ export const CustomLlmPublicIdSchema = GatewayModelIdSchema.refine(
message: `public_id must not start with ${RESERVED_CUSTOM_LLM_PUBLIC_ID_PREFIXES.join(', ')}`,
});
+export const CustomLlmInferenceProviderSlugSchema = z
+ .string()
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'must be a lowercase provider slug such as "acme-ai"');
+
+/**
+ * Makes a custom LLM available to every user, including anonymous ones. Like
+ * every custom LLM it is free; unlike private ones it is subject to
+ * organization provider allow lists.
+ */
+export const CustomLlmPublicSchema = z.object({
+ /** Provider slugs the upstream may serve this model from. They are listed in
+ * the models-by-provider snapshot and enforced against provider allow lists.
+ * A slug that OpenRouter does not know becomes a synthesized provider. */
+ inference_providers: z.array(CustomLlmInferenceProviderSlugSchema).min(1),
+ /** Shown in model pickers instead of the private custom LLM disclaimer. */
+ description: z.string().trim().min(1),
+});
+
+export type CustomLlmPublic = z.infer;
+
+export const CustomLlmDefinitionSchema = z
+ .object({
+ ...CustomLlmMetadataSchema.shape,
+ ...CustomLlmApiConfigSchema.shape,
+ display_name: z.string(),
+ /** Without organization or group grants, a private custom LLM is stored but reachable by nobody. */
+ organization_ids: z.array(z.uuid()).optional(),
+ group_ids: z.array(z.uuid()).optional(),
+ public: CustomLlmPublicSchema.optional(),
+ })
+ .superRefine((definition, ctx) => {
+ if (!definition.public) return;
+ for (const key of ['organization_ids', 'group_ids'] as const) {
+ if (definition[key] !== undefined) {
+ ctx.addIssue({
+ code: 'custom',
+ path: [key],
+ message: `${key} must not be set when public is set`,
+ });
+ }
+ }
+ });
+
+export type CustomLlmDefinition = z.infer;
+
export const AutoFreeModelSchema = z.object({
model: GatewayModelIdSchema,
weight: z.number().int().positive(),
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/custom-llm/access.ts b/packages/web-shared/src/lib/ai-gateway/custom-llm/access.ts
index 82798820a54..cf185d9baa0 100644
--- a/packages/web-shared/src/lib/ai-gateway/custom-llm/access.ts
+++ b/packages/web-shared/src/lib/ai-gateway/custom-llm/access.ts
@@ -9,7 +9,7 @@ export function hasCustomLlmAccess(
groupIds: readonly string[]
) {
return (
- definition.organization_ids.includes(organizationId) ||
+ definition.organization_ids?.includes(organizationId) === true ||
definition.group_ids?.some(groupId => groupIds.includes(groupId)) === true
);
}
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 5ffc701379e..f6f28e0f6b6 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,24 +2,38 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/gl
import { custom_llm2 } from '@kilocode/db/schema';
import {
findCustomLlm,
+ getCustomLlmInferenceProviders,
isCustomLlm,
+ lookupCustomLlm,
requireCustomLlmsById,
resetCustomLlmCacheForTest,
} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import { isFreeModel } from '@kilocode/web-shared/lib/ai-gateway/is-free-model';
+import { kiloExclusiveModels } from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import { db, readDb } from '@kilocode/web-shared/lib/drizzle';
import {
customLlmDefinition,
deleteCustomLlmForTest,
insertCustomLlmForTest,
+ publicCustomLlmDefinition,
} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
+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 () => {
- await insertCustomLlmForTest('Acme/Model');
+ await insertCustomLlmForTest('Acme/Public-Model', publicCustomLlmDefinition(['acme']));
+ await insertCustomLlmForTest('acme/private-model');
+ await insertCustomLlmForTest(exclusiveId);
});
afterAll(async () => {
- await deleteCustomLlmForTest('Acme/Model');
+ await deleteCustomLlmForTest('Acme/Public-Model');
+ await deleteCustomLlmForTest('acme/private-model');
+ await deleteCustomLlmForTest(exclusiveId);
});
afterEach(() => {
@@ -27,9 +41,11 @@ describe('custom LLM catalog', () => {
resetCustomLlmCacheForTest();
});
- test('finds custom LLMs by id regardless of case or prefix', async () => {
- await expect(findCustomLlm('acme/model')).resolves.toMatchObject({ public_id: 'Acme/Model' });
- await expect(isCustomLlm('ACME/MODEL')).resolves.toBe(true);
+ test('finds custom LLMs by id regardless of case', async () => {
+ await expect(findCustomLlm('acme/public-model')).resolves.toMatchObject({
+ public_id: 'Acme/Public-Model',
+ });
+ await expect(isCustomLlm('ACME/PRIVATE-MODEL')).resolves.toBe(true);
await expect(isCustomLlm('kilo-internal/unknown')).resolves.toBe(false);
});
@@ -51,7 +67,7 @@ describe('custom LLM catalog', () => {
}
});
- test('waits before retrying after a failed read', async () => {
+ test('fails closed only for Kilo-exclusive ids while the catalog cannot be read', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const now = Date.now();
const clock = jest.spyOn(Date, 'now').mockReturnValue(now);
@@ -59,12 +75,34 @@ describe('custom LLM catalog', () => {
throw new Error('database unavailable');
});
- await expect(isCustomLlm('acme/model')).resolves.toBe(false);
+ 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(getCustomLlmInferenceProviders(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);
clock.mockReturnValue(now + 5_000);
- await expect(isCustomLlm('acme/model')).resolves.toBe(true);
+ await expect(lookupCustomLlm('acme/public-model')).resolves.toMatchObject({
+ kind: 'custom-llm',
+ customLlm: { public_id: 'Acme/Public-Model' },
+ });
expect(select).toHaveBeenCalledTimes(2);
});
+
+ test('treats every custom LLM as free', async () => {
+ await expect(isFreeModel('acme/public-model')).resolves.toBe(true);
+ await expect(isFreeModel('acme/private-model')).resolves.toBe(true);
+ await expect(isFreeModel(exclusiveId)).resolves.toBe(true);
+ });
+
+ test('pins public custom LLMs to their providers and private ones to none', async () => {
+ await expect(getCustomLlmInferenceProviders('acme/public-model')).resolves.toEqual(
+ new Set(['acme'])
+ );
+ await expect(getCustomLlmInferenceProviders('acme/private-model')).resolves.toEqual(new Set());
+ await expect(getCustomLlmInferenceProviders(exclusiveId)).resolves.toEqual(new Set());
+ await expect(getCustomLlmInferenceProviders('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 3262130b19c..a355ddf79ef 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,7 +1,12 @@
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';
export type CustomLlm = {
@@ -10,6 +15,16 @@ export type CustomLlm = {
encrypted_api_key: CustomLlm2['encrypted_api_key'];
};
+export type PublicCustomLlmDefinition = CustomLlmDefinition & {
+ public: NonNullable;
+};
+
+export function isPublicCustomLlm(
+ definition: CustomLlmDefinition
+): definition is PublicCustomLlmDefinition {
+ return definition.public !== undefined;
+}
+
export function parseCustomLlmRows(rows: readonly CustomLlm2[]): CustomLlm[] {
return rows.flatMap(row => {
const parsed = CustomLlmDefinitionSchema.safeParse(row.definition);
@@ -68,7 +83,10 @@ async function loadCustomLlmsById(): Promise> {
}
}
-/** A failed refresh serves the previous value; with nothing cached the catalog is unavailable. */
+/**
+ * A failed refresh serves the previous value. With nothing cached the catalog
+ * is unavailable; see `lookupCustomLlm` for how routing treats that.
+ */
async function getCustomLlmCatalog(): Promise {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) {
return { byId: cache.byId, available: true };
@@ -119,6 +137,34 @@ export function resetCustomLlmCacheForTest() {
lastFailureAt = null;
}
+export type CustomLlmLookup =
+ | { kind: 'custom-llm'; customLlm: CustomLlm }
+ | { kind: 'none' }
+ | { kind: 'unknown' };
+
+/**
+ * Resolves a model id for routing. While the catalog is unavailable, an id is
+ * `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();
+ const catalog = await getCustomLlmCatalog();
+ const customLlm = catalog.byId.get(normalizedModelId);
+ if (customLlm) return { kind: 'custom-llm', customLlm };
+ if (
+ !catalog.available &&
+ CustomLlmPublicIdSchema.safeParse(normalizedModelId).success &&
+ kiloExclusiveModels.some(model => model.public_id === normalizedModelId)
+ ) {
+ return { kind: 'unknown' };
+ }
+ return { kind: 'none' };
+}
+
+/** A custom LLM for the id, or null when there is none or the catalog is unavailable. */
export async function findCustomLlm(modelId: string): Promise {
return (await getCustomLlmsById()).get(modelId.trim().toLowerCase()) ?? null;
}
@@ -126,3 +172,20 @@ export async function findCustomLlm(modelId: string): Promise
export async function isCustomLlm(modelId: string): Promise {
return (await findCustomLlm(modelId)) !== null;
}
+
+/**
+ * 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 getCustomLlmInferenceProviders(
+ modelId: string
+): Promise | 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/ai-gateway/custom-llm/listAvailableCustomLlms.ts b/packages/web-shared/src/lib/ai-gateway/custom-llm/listAvailableCustomLlms.ts
index bda177474f2..8c34ee2c679 100644
--- a/packages/web-shared/src/lib/ai-gateway/custom-llm/listAvailableCustomLlms.ts
+++ b/packages/web-shared/src/lib/ai-gateway/custom-llm/listAvailableCustomLlms.ts
@@ -1,14 +1,13 @@
-import { custom_llm2 } from '@kilocode/db/schema';
-import { readDb } from '@kilocode/web-shared/lib/drizzle';
-import { CustomLlmDefinitionSchema, type CustomLlmDefinition } from '@kilocode/db/schema-types';
+import type { CustomLlmDefinition } from '@kilocode/db/schema-types';
import { withLegacyVariants } from '@kilocode/web-shared/lib/ai-gateway/providers/legacy-variants';
import { orderOpenCodeSettings } from './order-opencode-variants';
import { hasCustomLlmAccess } from './access';
+import { fetchCustomLlmsFromDatabase, isPublicCustomLlm } from './custom-llm-catalog';
-const CUSTOM_LLM_DESCRIPTION =
+const PRIVATE_CUSTOM_LLM_DESCRIPTION =
'Access to this model was granted by a Kilo admin. This model has no availability or data retention guarantees. Do not use for mission critical workloads. Existence of the model may be confidential.';
-function convert(publicId: string, model: CustomLlmDefinition) {
+export function convertCustomLlmToCatalogModel(publicId: string, model: CustomLlmDefinition) {
const opencode = orderOpenCodeSettings(model.opencode_settings);
return {
id: publicId,
@@ -17,7 +16,7 @@ function convert(publicId: string, model: CustomLlmDefinition) {
name: model.display_name,
isCustomLlm: true,
created: 1756238927,
- description: CUSTOM_LLM_DESCRIPTION,
+ description: model.public?.description ?? PRIVATE_CUSTOM_LLM_DESCRIPTION,
context_length: model.context_length,
architecture: {
modality: model.supports_image_input ? 'text+image-\u003Etext' : 'text-\u003Etext',
@@ -50,17 +49,14 @@ function convert(publicId: string, model: CustomLlmDefinition) {
};
}
+/** Non-public custom LLMs granted to the organization or one of the groups.
+ * Public custom LLMs are part of the regular gateway catalog. */
export async function listAvailableCustomLlms(organizationId: string, groupIds: readonly string[]) {
- const rows = await readDb.select().from(custom_llm2);
- return rows
- .map(row => {
- const parsed = CustomLlmDefinitionSchema.safeParse(row.definition);
- if (!parsed.success) {
- console.log('Failed to parse custom llm definition', parsed.error);
- }
- return parsed.success ? { public_id: row.public_id, definition: parsed.data } : null;
- })
- .filter(row => row !== null)
- .filter(row => hasCustomLlmAccess(row.definition, organizationId, groupIds))
- .map(row => convert(row.public_id, row.definition));
+ return (await fetchCustomLlmsFromDatabase())
+ .filter(
+ row =>
+ !isPublicCustomLlm(row.definition) &&
+ hasCustomLlmAccess(row.definition, organizationId, groupIds)
+ )
+ .map(row => convertCustomLlmToCatalogModel(row.public_id, row.definition));
}
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 f63f028ec83..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
@@ -59,7 +59,6 @@ import { getDirectByokModel } from '@kilocode/web-shared/lib/ai-gateway/provider
import { rewriteModelResponse } from '@kilocode/web-shared/lib/ai-gateway/rewriteModelResponse';
import { readDb } from '@kilocode/web-shared/lib/drizzle';
import { isFreeModelRateLimited } from '@kilocode/web-shared/lib/ai-gateway/free-model-rate-limit';
-import { isCustomLlm } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import { gemma_4_26b_a4b_it_free_model } from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import { getEffectiveModelDecision } from '@kilocode/web-shared/lib/organizations/effective-model-access.server';
import { isNonTrialEnterpriseOrganization } from '@kilocode/web-shared/lib/organizations/non-trial-enterprise';
@@ -69,6 +68,11 @@ import {
releaseDecideLease,
type DecideResponse,
} from '@kilocode/web-shared/lib/bouncer/client';
+import {
+ isCustomLlm,
+ lookupCustomLlm,
+ type CustomLlm,
+} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import { NextRequest, NextResponse } from 'next/server';
import { handleLlmProxyRequest } from './llm-proxy';
@@ -100,6 +104,11 @@ jest.mock('@kilocode/web-shared/lib/creditTransactions', () => ({
}));
jest.mock('@kilocode/web-shared/lib/drizzle', () => ({ readDb: {} }));
jest.mock('@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog', () => ({
+ ...(jest.requireActual(
+ '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog'
+ ) as Record),
+ lookupCustomLlm: jest.fn(async () => ({ kind: 'none' })),
+ findCustomLlm: jest.fn(async () => null),
isCustomLlm: jest.fn(async () => false),
}));
jest.mock('@kilocode/web-shared/lib/ai-gateway/unavailable-models', () => ({
@@ -234,6 +243,11 @@ const mockedIsCustomLlm = jest.mocked(isCustomLlm);
const mockedGetEffectiveModelDecision = jest.mocked(getEffectiveModelDecision);
const mockedDecide = jest.mocked(decide);
const mockedIsNonTrialEnterpriseOrganization = jest.mocked(isNonTrialEnterpriseOrganization);
+const mockedLookupCustomLlm = jest.mocked(lookupCustomLlm);
+function mockCustomLlm(customLlm: CustomLlm) {
+ mockedLookupCustomLlm.mockResolvedValue({ kind: 'custom-llm', customLlm });
+ jest.mocked(isCustomLlm).mockResolvedValue(true);
+}
const provider = {
id: 'openrouter',
@@ -1394,10 +1408,30 @@ describe('POST /api/openrouter/v1/chat/completions request handling', () => {
expect(mockedUpstreamRequest).toHaveBeenCalledTimes(1);
});
+ function rateLimitTestCustomLlm(publicId: string, isPublic: boolean): CustomLlm {
+ return {
+ public_id: publicId,
+ encrypted_api_key: null,
+ definition: {
+ display_name: 'Custom model',
+ context_length: 1000,
+ max_completion_tokens: 100,
+ base_url: 'https://upstream.example.com/v1',
+ ...(isPublic
+ ? { public: { inference_providers: ['acme'], description: 'Public model' } }
+ : { organization_ids: [] }),
+ },
+ };
+ }
+
it.each(['kilo-internal/custom-model', 'acme/custom-model:free'])(
- 'does not apply free-model rate limiting to the custom LLM %s',
+ 'does not apply free-model rate limiting to the private custom LLM %s',
async customModelId => {
mockedIsCustomLlm.mockImplementation(async modelId => modelId === customModelId);
+ mockedLookupCustomLlm.mockResolvedValue({
+ kind: 'custom-llm',
+ customLlm: rateLimitTestCustomLlm(customModelId, false),
+ });
mockedIsFreeModelRateLimited.mockResolvedValue(true);
try {
@@ -1409,10 +1443,33 @@ describe('POST /api/openrouter/v1/chat/completions request handling', () => {
expect(mockedUpstreamRequest).toHaveBeenCalledTimes(1);
} finally {
mockedIsCustomLlm.mockImplementation(async () => false);
+ mockedLookupCustomLlm.mockResolvedValue({ kind: 'none' });
}
}
);
+ it('applies free-model rate limiting to a public custom LLM', async () => {
+ mockedIsCustomLlm.mockResolvedValue(true);
+ mockedLookupCustomLlm.mockResolvedValue({
+ kind: 'custom-llm',
+ customLlm: rateLimitTestCustomLlm('acme/public-model', true),
+ });
+ mockedIsFreeModelRateLimited.mockResolvedValue(true);
+
+ try {
+ const { handleLlmProxyRequest } = await import('./llm-proxy');
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody('acme/public-model')) as never
+ );
+
+ expect(response.status).toBe(429);
+ expect(mockedUpstreamRequest).not.toHaveBeenCalled();
+ } finally {
+ mockedIsCustomLlm.mockResolvedValue(false);
+ mockedLookupCustomLlm.mockResolvedValue({ kind: 'none' });
+ }
+ });
+
it('returns the reconnect error instead of serving another billing path when the ChatGPT connection is dead', async () => {
mockedGetProvider.mockResolvedValue({
kind: 'chatgpt-reconnect',
@@ -2794,3 +2851,201 @@ describe('custom routers and signed auto decisions', () => {
});
});
});
+
+describe('custom LLM requests', () => {
+ const customProvider = { ...provider, id: 'custom' } satisfies Provider;
+
+ function customLlm(publicId: string, isPublic: boolean): CustomLlm {
+ return {
+ public_id: publicId,
+ encrypted_api_key: null,
+ definition: {
+ display_name: 'Custom model',
+ context_length: 1000,
+ max_completion_tokens: 100,
+ base_url: 'https://upstream.example.com/v1',
+ ...(isPublic
+ ? { public: { inference_providers: ['acme'], description: 'Public model' } }
+ : { organization_ids: [] }),
+ },
+ };
+ }
+
+ function setAnonymousAuth() {
+ mockedGetUserFromAuth.mockResolvedValue({
+ user: null,
+ authFailedResponse: new Response('unauthorized', { status: 401 }),
+ organizationId: undefined,
+ } as AuthResult);
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setUserAuth();
+ mockedLookupCustomLlm.mockResolvedValue({ kind: 'none' });
+ jest.mocked(isCustomLlm).mockResolvedValue(false);
+ mockedGetProvider.mockResolvedValue({
+ kind: 'provider',
+ provider: customProvider,
+ userByok: null,
+ isFree: true,
+ bypassAccessCheck: false,
+ });
+ mockedUpstreamRequest.mockResolvedValue({
+ type: 'success',
+ response: upstreamJsonResponse({ id: 'chatcmpl-1', model: 'custom', choices: [] }),
+ });
+ });
+
+ it('serves a public custom LLM to an anonymous caller', async () => {
+ setAnonymousAuth();
+ mockCustomLlm(customLlm('acme/public-model', true));
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody('acme/public-model')) as never
+ );
+
+ expect(response.status).toBe(200);
+ expect(mockedGetProvider).toHaveBeenCalledWith(
+ expect.objectContaining({ user: expect.objectContaining({ isAnonymous: true }) })
+ );
+ });
+
+ it('serves a public custom LLM to a user without balance', async () => {
+ mockedGetBalanceAndOrgSettings.mockResolvedValue({
+ balance: 0,
+ settings: undefined,
+ plan: undefined,
+ });
+ mockCustomLlm(customLlm('acme/public-model', true));
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody('acme/public-model')) as never
+ );
+
+ expect(response.status).toBe(200);
+ });
+
+ it('refuses an anonymous caller of a private custom LLM without reaching an upstream', async () => {
+ setAnonymousAuth();
+ mockCustomLlm(customLlm('acme/private-model', false));
+ mockedGetProvider.mockResolvedValue({ kind: 'custom-llm-unavailable' });
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody('acme/private-model')) as never
+ );
+
+ expect(response.status).toBe(404);
+ expect(mockedUpstreamRequest).not.toHaveBeenCalled();
+ });
+
+ it('returns model not found instead of falling back when the caller is not eligible', async () => {
+ mockCustomLlm(customLlm('acme/private-model', false));
+ mockedGetProvider.mockResolvedValue({ kind: 'custom-llm-unavailable' });
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody('acme/private-model')) as never
+ );
+
+ expect(response.status).toBe(404);
+ expect(mockedUpstreamRequest).not.toHaveBeenCalled();
+ });
+
+ it('returns service unavailable when the custom LLM catalog cannot be read for an exclusive id', async () => {
+ mockedLookupCustomLlm.mockResolvedValue({ kind: 'unknown' });
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody(gemma_4_26b_a4b_it_free_model.public_id)) as never
+ );
+
+ expect(response.status).toBe(503);
+ expect(mockedGetProvider).not.toHaveBeenCalled();
+ });
+
+ it('rejects a public custom LLM when the request excludes its inference providers', async () => {
+ mockCustomLlm(customLlm('acme/public-model', true));
+
+ const response = await handleLlmProxyRequest(
+ makeRequest({ ...makeBody('acme/public-model'), provider: { only: ['openai'] } }) as never
+ );
+
+ expect(response.status).toBe(403);
+ await expect(response.json()).resolves.toMatchObject({ error_type: 'provider_not_allowed' });
+ expect(mockedUpstreamRequest).not.toHaveBeenCalled();
+ });
+
+ it('rejects a public custom LLM when organization policy allows none of its providers', async () => {
+ mockedGetUserFromAuth.mockResolvedValue({
+ user: { id: 'user-123', google_user_email: 'test@example.com', microdollars_used: 0 } as User,
+ authFailedResponse: null,
+ organizationId: 'org-123',
+ });
+ mockedGetEffectiveModelDecision.mockResolvedValueOnce({
+ allowed: false,
+ denialSource: 'organization_provider',
+ });
+ mockCustomLlm(customLlm('acme/public-model', true));
+
+ const response = await handleLlmProxyRequest(
+ makeRequest(makeBody('acme/public-model')) as never
+ );
+
+ expect(response.status).toBe(404);
+ await expect(response.json()).resolves.toMatchObject({ error_type: 'model_not_allowed' });
+ expect(mockedGetEffectiveModelDecision).toHaveBeenCalledWith(
+ expect.anything(),
+ 'acme/public-model'
+ );
+ 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 5b9d90f8860..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
@@ -38,7 +38,10 @@ import {
hasBestEffortGuessDataCollectionRequirement,
isFreeModel,
} from '@kilocode/web-shared/lib/ai-gateway/is-free-model';
-import { isCustomLlm } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import {
+ isPublicCustomLlm,
+ lookupCustomLlm,
+} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import {
accountForMicrodollarUsage,
captureProxyError,
@@ -67,6 +70,7 @@ import {
storeAndPreviousResponseIdIsNotSupported,
apiKindNotSupportedResponse,
checkExclusiveModelProviderAllowed,
+ checkInferenceProvidersAllowed,
modelDoesNotExistOnOpenRouterResponse,
chatGptReconnectResponse,
lastUserPromptText,
@@ -308,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(
@@ -584,6 +590,13 @@ export async function handleLlmProxyRequest(
}
const effectiveModelIdLowerCased = requestBodyParsed.body.model.toLowerCase();
+ // A custom LLM takes precedence over any built-in model with the same id,
+ // including Kilo-exclusive models, so exclusive-model rules do not apply to it.
+ const customLlmLookup = await lookupCustomLlm(effectiveModelIdLowerCased);
+ if (customLlmLookup.kind === 'unknown') {
+ return temporarilyUnavailableResponse();
+ }
+ const customLlm = customLlmLookup.kind === 'custom-llm' ? customLlmLookup.customLlm : null;
// Custom LLMs count as free; `getProvider` still refuses callers without access.
const isFreeRequestedModel = await isFreeModel(effectiveModelIdLowerCased);
@@ -604,8 +617,10 @@ export async function handleLlmProxyRequest(
// For free models: server-side products (cloud-agent, code-review, app-builder,
// gastown) rate-limit per user when the request comes from Cloudflare IPs (Kilo
// infrastructure). All other products rate-limit per IP (fast pre-auth path).
- // Custom LLMs are exempt, even when their id matches a free-model pattern.
- if (isFreeRequestedModel && !(await isCustomLlm(effectiveModelIdLowerCased))) {
+ // Private custom LLMs are exempt; public ones are open to everyone, like any
+ // other free model.
+ const isPrivateCustomLlm = customLlm !== null && !isPublicCustomLlm(customLlm.definition);
+ if (isFreeRequestedModel && !isPrivateCustomLlm) {
const rateLimitKey = await resolveFreeModelRateLimitKey(feature, ipAddress, authPromise);
if (rateLimitKey instanceof NextResponse) return rateLimitKey;
@@ -849,6 +864,9 @@ export async function handleLlmProxyRequest(
// Same response as an unknown model id, so callers cannot probe for custom LLMs.
return modelDoesNotExistOnOpenRouterResponse(effectiveModelIdLowerCased);
}
+ if (providerResult.kind === 'custom-llm-temporarily-unavailable') {
+ return temporarilyUnavailableResponse();
+ }
const effectiveProviderContext = providerResult;
if (autoModel === ORG_AUTO_MODEL.id && routingTarget) {
@@ -883,7 +901,7 @@ export async function handleLlmProxyRequest(
return temporarilyUnavailableResponse();
}
- if (!autoModel && (await isUnavailableModel(effectiveModelIdLowerCased))) {
+ if (!customLlm && !autoModel && (await isUnavailableModel(effectiveModelIdLowerCased))) {
console.warn(`User requested unavailable model ${effectiveModelIdLowerCased}; rejecting.`);
return unavailableModelResponse();
}
@@ -983,10 +1001,23 @@ export async function handleLlmProxyRequest(
return dataCollectionRequiredResponse();
}
- const providerNotAllowedError = checkExclusiveModelProviderAllowed(
- effectiveModelIdLowerCased,
- requestBodyParsed.body.provider
- );
+ // 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(
+ customLlm.definition.public.inference_providers,
+ requestProvider
+ ) ??
+ checkInferenceProvidersAllowed(
+ customLlm.definition.public.inference_providers,
+ requestBodyParsed.body.provider
+ ))
+ : null
+ : checkExclusiveModelProviderAllowed(
+ effectiveModelIdLowerCased,
+ requestBodyParsed.body.provider
+ );
if (providerNotAllowedError) return providerNotAllowedError;
sentryRootSpan()?.setAttribute(
diff --git a/packages/web-shared/src/lib/ai-gateway/llm-proxy-helpers.ts b/packages/web-shared/src/lib/ai-gateway/llm-proxy-helpers.ts
index ca135e3f2c5..54e7697e30d 100644
--- a/packages/web-shared/src/lib/ai-gateway/llm-proxy-helpers.ts
+++ b/packages/web-shared/src/lib/ai-gateway/llm-proxy-helpers.ts
@@ -228,11 +228,22 @@ export function checkExclusiveModelProviderAllowed(
model: string,
provider: OpenRouterProviderConfig | undefined
) {
- if (!provider) return null;
const exclusiveModel = findKiloExclusiveModel(model);
if (!exclusiveModel) return null;
const inferenceProviders = exclusiveModel.inference_providers;
if (inferenceProviders.length === 0) return null;
+ return checkInferenceProvidersAllowed(inferenceProviders, provider);
+}
+
+/**
+ * Rejects a request whose provider config excludes every provider that can
+ * serve the model. Used for models routed to a fixed set of providers.
+ */
+export function checkInferenceProvidersAllowed(
+ inferenceProviders: readonly string[],
+ provider: OpenRouterProviderConfig | undefined
+) {
+ if (!provider) return null;
if (
inferenceProviders.some(
inferenceProvider =>
diff --git a/packages/web-shared/src/lib/ai-gateway/processUsage.ts b/packages/web-shared/src/lib/ai-gateway/processUsage.ts
index 9145cc905e7..04de5535483 100644
--- a/packages/web-shared/src/lib/ai-gateway/processUsage.ts
+++ b/packages/web-shared/src/lib/ai-gateway/processUsage.ts
@@ -1434,7 +1434,10 @@ export async function processTokenData(
usageStats.model = usageContext.requested_model;
}
- const kiloExclusiveModel = findKiloExclusiveModel(usageContext.requested_model);
+ const kiloExclusiveModel =
+ usageContext.provider === 'custom'
+ ? null
+ : findKiloExclusiveModel(usageContext.requested_model);
if (kiloExclusiveModel?.pricing) {
const exclusiveCost_mUsd = calculateKiloExclusiveCost_mUsd(kiloExclusiveModel, usageStats);
if (exclusiveCost_mUsd !== undefined) {
diff --git a/packages/web-shared/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts b/packages/web-shared/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts
index 7cf2c1d8b74..9642c9daff6 100644
--- a/packages/web-shared/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts
+++ b/packages/web-shared/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts
@@ -367,7 +367,9 @@ export async function applyProviderSpecificLogic(
enableReasoningSummaries(requestToMutate);
- const kiloExclusiveModel = findKiloExclusiveModel(requestedModel);
+ // A custom LLM may reuse a Kilo-exclusive id and takes precedence over it.
+ const kiloExclusiveModel =
+ provider.id === 'custom' ? null : findKiloExclusiveModel(requestedModel);
removeUnsupportedRequestServiceTier(requestedModel, requestToMutate, kiloExclusiveModel);
if (kiloExclusiveModel) {
applyKiloExclusiveModelSettings(requestToMutate, kiloExclusiveModel);
diff --git a/packages/web-shared/src/lib/ai-gateway/providers/get-provider.public-custom-llm.test.ts b/packages/web-shared/src/lib/ai-gateway/providers/get-provider.public-custom-llm.test.ts
new file mode 100644
index 00000000000..f758baf1627
--- /dev/null
+++ b/packages/web-shared/src/lib/ai-gateway/providers/get-provider.public-custom-llm.test.ts
@@ -0,0 +1,100 @@
+import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals';
+import { getProvider } from '@kilocode/web-shared/lib/ai-gateway/providers/get-provider';
+import { encryptApiKey } from '@kilocode/web-shared/lib/ai-gateway/byok/encryption';
+import { BYOK_ENCRYPTION_KEY } from '@kilocode/web-shared/lib/config.server';
+import { createAnonymousContext } from '@kilocode/web-shared/lib/anonymous';
+import { kiloExclusiveModels } from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
+import { resetCustomLlmCacheForTest } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import { readDb } from '@kilocode/web-shared/lib/drizzle';
+import type { GatewayRequest } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/types';
+import type { User } from '@kilocode/db/schema';
+import {
+ customLlmDefinition,
+ deleteCustomLlmForTest,
+ insertCustomLlmForTest,
+ publicCustomLlmDefinition,
+} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
+
+const ORG_ID = '00000000-0000-4000-8000-000000000001';
+const OTHER_ORG_ID = '00000000-0000-4000-8000-000000000002';
+const user = { id: 'user-id' } as User;
+const encryptedApiKey = encryptApiKey(
+ JSON.stringify({ type: 'api_key', api_key: 'sk-upstream' }),
+ BYOK_ENCRYPTION_KEY
+);
+const shadowedExclusiveId =
+ kiloExclusiveModels.find(model => !model.public_id.includes(':'))?.public_id ?? '';
+
+function providerInput(
+ requestedModel: string,
+ caller: User | ReturnType,
+ organizationId: string | undefined
+) {
+ return {
+ requestedModel,
+ request: {
+ kind: 'chat_completions',
+ body: { model: requestedModel, messages: [] },
+ } satisfies GatewayRequest,
+ user: caller,
+ organizationId,
+ };
+}
+
+describe('getProvider with public custom LLMs', () => {
+ beforeAll(async () => {
+ await insertCustomLlmForTest(
+ 'acme/public-model',
+ publicCustomLlmDefinition(['acme']),
+ encryptedApiKey
+ );
+ await insertCustomLlmForTest(
+ shadowedExclusiveId,
+ customLlmDefinition({ organization_ids: [ORG_ID] }),
+ encryptedApiKey
+ );
+ });
+
+ afterAll(async () => {
+ await deleteCustomLlmForTest('acme/public-model');
+ await deleteCustomLlmForTest(shadowedExclusiveId);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ resetCustomLlmCacheForTest();
+ });
+
+ test('routes a public custom LLM for an anonymous caller through policy checks', async () => {
+ const result = await getProvider(
+ providerInput('acme/public-model', createAnonymousContext('127.0.0.1'), undefined)
+ );
+
+ expect(result).toMatchObject({
+ kind: 'provider',
+ provider: { id: 'custom', apiUrl: 'https://upstream.example.com/v1', apiKey: 'sk-upstream' },
+ bypassAccessCheck: false,
+ });
+ });
+
+ test('takes precedence over a Kilo-exclusive model with the same id', async () => {
+ await expect(
+ getProvider(providerInput(shadowedExclusiveId, user, ORG_ID))
+ ).resolves.toMatchObject({ kind: 'provider', provider: { id: 'custom' } });
+ await expect(
+ getProvider(providerInput(shadowedExclusiveId, user, OTHER_ORG_ID))
+ ).resolves.toEqual({ kind: 'custom-llm-unavailable' });
+ });
+
+ test('refuses a Kilo-exclusive id while the custom LLM catalog cannot be read', async () => {
+ resetCustomLlmCacheForTest();
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ jest.spyOn(readDb, 'select').mockImplementationOnce(() => {
+ throw new Error('database unavailable');
+ });
+
+ await expect(getProvider(providerInput(shadowedExclusiveId, user, ORG_ID))).resolves.toEqual({
+ kind: 'custom-llm-temporarily-unavailable',
+ });
+ });
+});
diff --git a/packages/web-shared/src/lib/ai-gateway/providers/get-provider.ts b/packages/web-shared/src/lib/ai-gateway/providers/get-provider.ts
index 3067278ee46..7cd2b8f7b33 100644
--- a/packages/web-shared/src/lib/ai-gateway/providers/get-provider.ts
+++ b/packages/web-shared/src/lib/ai-gateway/providers/get-provider.ts
@@ -22,16 +22,17 @@ import { VERCEL_AI_GATEWAY } from '@kilocode/web-shared/lib/ai-gateway/providers
import { getDirectByokModel } from '@kilocode/web-shared/lib/ai-gateway/providers/direct-byok';
import { checkOpenAiChatGptByok } from '@kilocode/web-shared/lib/ai-gateway/openai-chatgpt/routing';
import { CustomLlmCredentialsSchema, type CustomLlmCredentials } from '@kilocode/db/schema-types';
+import {
+ lookupCustomLlm,
+ isPublicCustomLlm,
+ type CustomLlm,
+} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import {
buildDirectProvider,
type DirectUpstreamAuth,
} from '@kilocode/web-shared/lib/ai-gateway/providers/build-direct-provider';
import { getGoogleServiceAccountAccessToken } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/google-service-account';
import { userHasCustomLlmAccess } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/access';
-import {
- findCustomLlm,
- type CustomLlm,
-} from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import { decryptApiKey } from '@kilocode/web-shared/lib/ai-gateway/byok/encryption';
import { BYOK_ENCRYPTION_KEY } from '@kilocode/web-shared/lib/config.server';
import { isFreeModelExcludingCustomLlms } from '@kilocode/web-shared/lib/ai-gateway/is-free-model';
@@ -48,8 +49,8 @@ export type GetProviderProviderResult = {
userByok: BYOKResult[] | null;
readonly isFree: boolean;
/** Skip balance, paid-auth, and organization policy checks entirely. Used
- * by direct-byok and custom_llm2 because both already require explicit
- * admin opt-in. */
+ * by direct-byok and non-public custom_llm2 because both already require
+ * explicit admin opt-in. */
bypassAccessCheck: boolean;
/** Skip only the zero-balance paid-model block. Set when a user credential
* outside Kilo credits pays for the request, such as the ChatGPT
@@ -61,7 +62,9 @@ export type GetProviderResult =
| GetProviderProviderResult
| { kind: 'chatgpt-reconnect'; message: string }
/** The id is a custom LLM the caller may not use, or its stored credentials are unusable. */
- | { kind: 'custom-llm-unavailable' };
+ | { kind: 'custom-llm-unavailable' }
+ /** The custom LLM catalog cannot be read and the id may belong to a custom LLM. */
+ | { kind: 'custom-llm-temporarily-unavailable' };
async function checkDirectBYOK(
user: User | AnonymousUserContext,
@@ -101,6 +104,28 @@ async function checkDirectBYOK(
};
}
+async function isEligibleForCustomLlm(
+ customLlm: CustomLlm,
+ user: User | AnonymousUserContext,
+ organizationId: string | undefined
+) {
+ if (isPublicCustomLlm(customLlm.definition)) return true;
+ if (isAnonymousContext(user) || !organizationId) return false;
+ return await userHasCustomLlmAccess(customLlm.definition, organizationId, user.id);
+}
+
+function readCustomLlmCredentials(customLlm: CustomLlm): CustomLlmCredentials | null {
+ if (!customLlm.encrypted_api_key) return null;
+ const decrypted = decryptApiKey(customLlm.encrypted_api_key, BYOK_ENCRYPTION_KEY);
+ let parsedJson: unknown;
+ try {
+ parsedJson = JSON.parse(decrypted);
+ } catch {
+ return null;
+ }
+ return CustomLlmCredentialsSchema.safeParse(parsedJson).data ?? null;
+}
+
async function resolveCustomLlmAuth(
credentials: CustomLlmCredentials
): Promise {
@@ -121,58 +146,41 @@ async function resolveCustomLlmAuth(
}
/**
- * A custom LLM owns its id: a caller without access, or a definition with
- * unusable credentials, must not fall back to another upstream for the same id.
+ * A custom LLM owns its id: an ineligible user or a broken definition must not
+ * fall back to OpenRouter or a Kilo-exclusive model with the same id.
*/
async function resolveCustomLlmProvider(
- { public_id, definition: customLlm, encrypted_api_key }: CustomLlm,
+ customLlm: CustomLlm,
user: User | AnonymousUserContext,
organizationId: string | undefined
): Promise {
- if (
- isAnonymousContext(user) ||
- !organizationId ||
- !(await userHasCustomLlmAccess(customLlm, organizationId, user.id))
- ) {
- return { kind: 'custom-llm-unavailable' };
- }
-
- if (!encrypted_api_key) {
- console.error('Custom LLM has no stored credentials', public_id);
- return { kind: 'custom-llm-unavailable' };
- }
-
- const decrypted = decryptApiKey(encrypted_api_key, BYOK_ENCRYPTION_KEY);
- let parsedJson: unknown;
- try {
- parsedJson = JSON.parse(decrypted);
- } catch {
- console.error('Custom LLM credentials are not valid JSON', public_id);
+ if (!(await isEligibleForCustomLlm(customLlm, user, organizationId))) {
return { kind: 'custom-llm-unavailable' };
}
- const parsedCredentials = CustomLlmCredentialsSchema.safeParse(parsedJson);
- if (!parsedCredentials.success) {
- console.error('Custom LLM credentials do not match the schema', public_id);
+ const credentials = readCustomLlmCredentials(customLlm);
+ if (!credentials) {
+ console.error('Custom LLM credentials are missing or invalid', customLlm.public_id);
return { kind: 'custom-llm-unavailable' };
}
+ const { definition } = customLlm;
return {
kind: 'provider',
provider: buildDirectProvider(
'custom',
[
- customLlm.opencode_settings?.ai_sdk_provider === 'anthropic'
+ definition.opencode_settings?.ai_sdk_provider === 'anthropic'
? 'messages'
- : customLlm.opencode_settings?.ai_sdk_provider === 'openai'
+ : definition.opencode_settings?.ai_sdk_provider === 'openai'
? 'responses'
: 'chat_completions',
],
- customLlm,
- await resolveCustomLlmAuth(parsedCredentials.data)
+ definition,
+ await resolveCustomLlmAuth(credentials)
),
userByok: null,
isFree: true,
- bypassAccessCheck: true,
+ bypassAccessCheck: !isPublicCustomLlm(definition),
};
}
@@ -204,7 +212,7 @@ export type GetProviderInput = {
/** The platform caller for a service run; see `OpenAiChatGptRoutingInput`. */
botId?: string | undefined;
/** Resolves organization/group provider policy only when selecting a managed
- * gateway. Direct BYOK and custom LLM routes remain exempt. */
+ * gateway. Direct BYOK and custom LLM routes do not use it. */
getRoutingProviderConfig?: () => Promise;
};
@@ -224,9 +232,12 @@ export async function getProvider(input: GetProviderInput): Promise customLlmsById.has(modelId.toLowerCase());
+ const publicCustomLlms = [...customLlmsById.values()].flatMap(customLlm =>
+ isPublicCustomLlm(customLlm.definition)
+ ? [{ ...customLlm, definition: customLlm.definition }]
+ : []
+ );
+ const publicCustomLlmProviders = new Map(
+ publicCustomLlms.map(({ public_id, definition }) => [
+ public_id,
+ definition.public.inference_providers,
+ ])
+ );
+ const publicCustomLlmModels = publicCustomLlms.map(({ public_id, definition }) =>
+ convertCustomLlmToCatalogModel(public_id, definition)
+ );
const hasEndpointsMetadata = Object.keys(endpointsMetadata).length > 0;
const summaries = await getTerminalBenchSummaries();
const catalogModels = models
.filter(
model =>
(!hasEndpointsMetadata || endpointsMetadata[model.id] !== undefined) &&
+ !isShadowedByCustomLlm(model.id) &&
!kiloExclusiveModels.some(m => m.public_id === model.id) &&
!unavailableModelIds.has(model.id) &&
!model.id.endsWith(':batch')
@@ -164,12 +192,19 @@ async function enhancedModelList(models: OpenRouterModel[]) {
})
.concat(
kiloExclusiveModels
- .filter(m => !unavailableModelIds.has(m.public_id))
+ .filter(m => !unavailableModelIds.has(m.public_id) && !isShadowedByCustomLlm(m.public_id))
.map(model => convertFromKiloExclusiveModel(model))
)
.concat(autoModels)
- .map(applyCustomPricingToModel);
- const exclusiveById = new Map(kiloExclusiveModels.map(entry => [entry.public_id, entry]));
+ .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))
+ .map(entry => [entry.public_id, entry])
+ );
const providerLookupId = (model: OpenRouterModel) =>
exclusiveById.get(model.id)?.internal_id ?? model.alias_target ?? model.id;
const indexedProviders = await getProviderSlugsForModels(catalogModels.map(providerLookupId));
@@ -178,17 +213,22 @@ async function enhancedModelList(models: OpenRouterModel[]) {
const preferredIndex = preferredModels.indexOf(model.id);
const exclusive = exclusiveById.get(model.id);
const providerIds =
- exclusive && exclusive.inference_providers.length > 0
+ publicCustomLlmProviders.get(model.id) ??
+ (exclusive && exclusive.inference_providers.length > 0
? exclusive.inference_providers
- : (indexedProviders.get(providerLookupId(model)) ?? []);
+ : (indexedProviders.get(providerLookupId(model)) ?? []));
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,
@@ -199,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,
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 7e966967fc2..0237df89456 100644
--- a/packages/web-shared/src/lib/model-allow.server.test.ts
+++ b/packages/web-shared/src/lib/model-allow.server.test.ts
@@ -3,6 +3,7 @@ import {
customLlmDefinition,
deleteCustomLlmForTest,
insertCustomLlmForTest,
+ publicCustomLlmDefinition,
} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
import {
createAllowPredicateFromProviderAllowList,
@@ -26,12 +27,19 @@ jest.mock('@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models', () => {
: actual.getKiloExclusiveInferenceProviders(modelId),
};
});
-
function lookup(map: Record): ProviderLookup {
return async modelId => new Set(map[modelId] ?? []);
}
describe('model access predicates', () => {
+ beforeAll(async () => {
+ await insertCustomLlmForTest('acme/public-model', publicCustomLlmDefinition(['acme']));
+ });
+
+ afterAll(async () => {
+ await deleteCustomLlmForTest('acme/public-model');
+ });
+
test('undefined provider allow list only applies model deny list', async () => {
const isAllowed = createAllowPredicateFromProviderAllowList(
['openai/gpt-4o'],
@@ -215,6 +223,31 @@ describe('model access predicates', () => {
}
);
+ test('applies provider allow lists to the providers of a public custom LLM', async () => {
+ const restrictions = { requireModelInCurrentSnapshot: true, modelDenyList: [] };
+ const allowsAcme = createAllowPredicateFromRestrictions(
+ { ...restrictions, providerAllowList: ['acme'] },
+ lookup({})
+ );
+ const allowsOpenAi = createAllowPredicateFromRestrictions(
+ { ...restrictions, providerAllowList: ['openai'] },
+ lookup({ 'acme/public-model': ['openai'] })
+ );
+ const deniesModel = createAllowPredicateFromRestrictions(
+ {
+ ...restrictions,
+ providerAllowList: ['acme'],
+ modelDenyList: ['acme/public-model'],
+ customLlmGrantee: { organizationId: '00000000-0000-4000-8000-0000000000a1', groupIds: [] },
+ },
+ lookup({})
+ );
+
+ await expect(allowsAcme('acme/public-model')).resolves.toBe(true);
+ await expect(allowsOpenAi('acme/public-model')).resolves.toBe(false);
+ await expect(deniesModel('acme/public-model')).resolves.toBe(false);
+ });
+
test('provider allow list still applies model deny list', async () => {
const isAllowed = createAllowPredicateFromProviderAllowList(
['openai/gpt-4o'],
@@ -260,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 59ec49b6dd2..eb91b0ef6e6 100644
--- a/packages/web-shared/src/lib/model-allow.server.ts
+++ b/packages/web-shared/src/lib/model-allow.server.ts
@@ -4,7 +4,10 @@ import {
KILO_AUTO_MODEL_PREFIX,
normalizeModelId,
} from '@kilocode/web-shared/lib/ai-gateway/model-utils';
-import { findCustomLlm } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
+import {
+ findCustomLlm,
+ getCustomLlmInferenceProviders,
+} 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';
import { getProviderSlugsForModel } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/models-by-provider-index.server';
@@ -52,6 +55,19 @@ export async function isModelRestrictionExempt(
return directByokModel.provider !== null && directByokModel.model !== null;
}
+/**
+ * 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 getModelInferenceProviders(
+ modelId: string
+): Promise | undefined> {
+ return (
+ (await getCustomLlmInferenceProviders(modelId)) ?? getKiloExclusiveInferenceProviders(modelId)
+ );
+}
+
export function hasActiveModelRestrictions(restrictions: ModelRestrictions): boolean {
return restrictions.providerAllowList !== undefined || restrictions.modelDenyList.length > 0;
}
@@ -73,7 +89,7 @@ export function createAllowPredicateFromProviderAllowList(
return false;
}
const providerSlugs =
- getKiloExclusiveInferenceProviders(modelId) ?? (await providerLookup(modelId));
+ (await getModelInferenceProviders(modelId)) ?? (await providerLookup(modelId));
if (providerSlugs.size === 0) return false;
if (!providerAllowSet) return true;
return [...providerSlugs].some(slug => providerAllowSet.has(slug));
diff --git a/packages/web-shared/src/lib/organizations/effective-model-access.server.test.ts b/packages/web-shared/src/lib/organizations/effective-model-access.server.test.ts
index 1e5bd568a57..b875ec04754 100644
--- a/packages/web-shared/src/lib/organizations/effective-model-access.server.test.ts
+++ b/packages/web-shared/src/lib/organizations/effective-model-access.server.test.ts
@@ -3,6 +3,7 @@ import {
customLlmDefinition,
deleteCustomLlmForTest,
insertCustomLlmForTest,
+ publicCustomLlmDefinition,
} from '@kilocode/web-shared/tests/helpers/custom-llm.helper';
import type { OrganizationGroupPolicyContext } from './organization-group-policy-context.server';
import {
@@ -174,12 +175,14 @@ describe('effective organization model access', () => {
'acme/other-org-model',
customLlmDefinition({ organization_ids: ['00000000-0000-4000-8000-0000000000a2'] })
);
+ await insertCustomLlmForTest('acme/public-model', publicCustomLlmDefinition(['acme']));
});
afterAll(async () => {
await deleteCustomLlmForTest('acme/org-model');
await deleteCustomLlmForTest('acme/group-model');
await deleteCustomLlmForTest('acme/other-org-model');
+ await deleteCustomLlmForTest('acme/public-model');
});
it('exempts a custom LLM granted to the organization', async () => {
@@ -210,6 +213,34 @@ describe('effective organization model access', () => {
).resolves.toEqual({ allowed: false, denialSource: 'organization_model' });
}
);
+
+ it('routes a public custom LLM only to its allowed inference providers', async () => {
+ const withProviderAllowList = (provider_allow_list: string[]) =>
+ evaluateEffectiveModelAccessPolicy(
+ context({
+ organization: {
+ ...organization,
+ settings: { model_deny_list: [], provider_allow_list },
+ },
+ defaultPolicies: [{ type: 'model_access', data: { mode: 'all' } }],
+ })
+ );
+
+ await expect(
+ getEffectiveModelDecision(
+ withProviderAllowList(['acme', 'openai']),
+ 'acme/public-model',
+ async () => new Set()
+ )
+ ).resolves.toEqual({ allowed: true, eligibleProviderRoutes: new Set(['acme']) });
+ await expect(
+ getEffectiveModelDecision(
+ withProviderAllowList(['openai']),
+ 'acme/public-model',
+ async () => new Set()
+ )
+ ).resolves.toEqual({ allowed: false, denialSource: 'organization_provider' });
+ });
});
it.each(['kilo-auto/balanced', 'kimi-coding/kimi-for-coding'])(
diff --git a/packages/web-shared/src/lib/organizations/group-policies/model-access/model-access.server.ts b/packages/web-shared/src/lib/organizations/group-policies/model-access/model-access.server.ts
index 5a7807e9ef7..4247b207832 100644
--- a/packages/web-shared/src/lib/organizations/group-policies/model-access/model-access.server.ts
+++ b/packages/web-shared/src/lib/organizations/group-policies/model-access/model-access.server.ts
@@ -5,7 +5,6 @@ import { modelsByProvider, organizations } from '@kilocode/db/schema';
import type { OrganizationSettings } from '@kilocode/db/schema-types';
import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm';
-import { getKiloExclusiveInferenceProviders } from '@kilocode/web-shared/lib/ai-gateway/kilo-exclusive-models';
import { normalizeModelId } from '@kilocode/web-shared/lib/ai-gateway/model-utils';
import { normalizeInferenceProviderId } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { getProviderSlugsForModel } from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/models-by-provider-index.server';
@@ -14,6 +13,7 @@ import {
withoutVirtualProvider,
} from '@kilocode/web-shared/lib/ai-gateway/providers/openrouter/virtual-models';
import {
+ getModelInferenceProviders,
isModelRestrictionExempt,
type CustomLlmGrantee,
} from '@kilocode/web-shared/lib/model-allow.server';
@@ -169,9 +169,9 @@ export async function getEffectiveModelDecision(
) {
return { allowed: false, denialSource: 'organization_model' };
}
- const exclusiveProviders = getKiloExclusiveInferenceProviders(modelId);
+ const pinnedProviders = await getModelInferenceProviders(modelId);
const currentModelProviders =
- exclusiveProviders ??
+ pinnedProviders ??
(policy.requireModelInCurrentSnapshot ? await providerLookup(modelId) : undefined);
if (currentModelProviders?.size === 0) {
return { allowed: false, denialSource: 'organization_model' };
diff --git a/packages/web-shared/src/tests/helpers/custom-llm.helper.ts b/packages/web-shared/src/tests/helpers/custom-llm.helper.ts
index 1ce6d1d01d3..ba5b4cd2157 100644
--- a/packages/web-shared/src/tests/helpers/custom-llm.helper.ts
+++ b/packages/web-shared/src/tests/helpers/custom-llm.helper.ts
@@ -4,20 +4,37 @@ import { db } from '@kilocode/web-shared/lib/drizzle';
import { resetCustomLlmCacheForTest } from '@kilocode/web-shared/lib/ai-gateway/custom-llm/custom-llm-catalog';
import { eq } from 'drizzle-orm';
+const baseDefinition = {
+ internal_id: 'upstream-model',
+ context_length: 128_000,
+ max_completion_tokens: 4096,
+ base_url: 'https://upstream.example.com/v1',
+};
+
+/** A private custom LLM granted to the given organizations (none by default). */
export function customLlmDefinition(
overrides: Partial = {}
): CustomLlmDefinition {
return {
- internal_id: 'upstream-model',
+ ...baseDefinition,
display_name: 'Custom LLM',
- context_length: 128_000,
- max_completion_tokens: 4096,
- base_url: 'https://upstream.example.com/v1',
organization_ids: [],
...overrides,
};
}
+export function publicCustomLlmDefinition(
+ inferenceProviders: string[],
+ overrides: Partial = {}
+): CustomLlmDefinition {
+ return {
+ ...baseDefinition,
+ display_name: 'Public custom LLM',
+ public: { inference_providers: inferenceProviders, description: 'A public custom LLM' },
+ ...overrides,
+ };
+}
+
export async function insertCustomLlmForTest(
publicId: string,
definition: CustomLlmDefinition = customLlmDefinition(),