Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 206 additions & 56 deletions apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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,
description: 'A free Acme model',
supports_image_input: true,
public: { inference_providers: ['acme', 'together', 'acme'] },
},
};

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 },
},
});
});
});
Original file line number Diff line number Diff line change
@@ -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.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 },
}));
});
}
27 changes: 23 additions & 4 deletions apps/web/src/lib/providers/openrouter/sync-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -153,8 +161,9 @@ async function syncProviders(params: {
openRouterModels: Record<string, StoredModel>;
openRouterCatalogModels: CatalogModel[];
vercelModels: Record<string, StoredModel>;
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');
}
Expand Down Expand Up @@ -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)
? []
Expand Down Expand Up @@ -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(
Expand All @@ -246,7 +264,7 @@ async function syncProviders(params: {
applyFreeEndpointDataPolicy({
providerModelData,
openRouterFreeEndpoints,
kiloExclusiveModels,
kiloExclusiveModels: unshadowedKiloExclusiveModels,
unavailableModelIds,
});

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
93 changes: 83 additions & 10 deletions apps/web/src/routers/admin/custom-llm-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ const validDefinition: CustomLlmDefinition = {
group_ids: ['00000000-0000-4000-8000-000000000123'],
};

const publicDefinition: CustomLlmDefinition = {
internal_id: 'upstream-model',
display_name: 'Public model',
description: 'A public custom model',
context_length: 128000,
max_completion_tokens: 4096,
base_url: 'https://upstream.example.com/v1',
public: { inference_providers: ['acme'] },
};

beforeEach(async () => {
jest
.mocked(getOpenRouterModelsMetadataFromDatabase)
Expand Down Expand Up @@ -262,6 +272,16 @@ describe('adminCustomLlmRouter', () => {
expect(item!.public_id).toBe(publicId);
expect((item as Record<string, unknown>).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', () => {
Expand Down Expand Up @@ -459,23 +479,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 () => {
Expand Down Expand Up @@ -529,6 +549,59 @@ 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'] });
});

it.each([
['organization_ids', { organization_ids: [] }],
['group_ids', { group_ids: ['00000000-0000-4000-8000-000000000123'] }],
['description', { description: undefined }],
])('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('requires organization_ids on a private definition', async () => {
const caller = await createCallerForUser(admin.id);
const { organization_ids: _organizationIds, ...definition } = validDefinition;

await expect(
caller.admin.customLlm.upsert({ public_id: 'acme/private-model', definition, credentials })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});

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'] } },
credentials,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});

describe('delete', () => {
it('deletes a custom LLM by public_id', async () => {
const caller = await createCallerForUser(admin.id);
Expand Down
24 changes: 16 additions & 8 deletions apps/web/src/routers/admin/custom-llm-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading
Loading