Skip to content

Commit a45a2c3

Browse files
feat(model-experiments): reject reserved public_model_id prefixes (#3618)
* feat(model-experiments): reject reserved public_model_id prefixes Creating or updating a model experiment with a public_model_id under the kilo/, kilocode/, or kilo-internal/ namespaces now fails validation, since those are reserved for Kilo-owned models and must not be claimed by partner experiment public ids. * refactor(model-experiments): centralize Kilo provider prefix constants Introduce KILOCODE_KILO_PROVIDER_PREFIX, KILOCLAW_KILO_PROVIDER_PREFIX, and CUSTOM_LLM_PREFIX in the shared model-utils module and use them across apps/web instead of repeated string literals. Also rename pick-variant.test.ts public ids off the now-reserved kilo/ namespace so the suite keeps passing, and fix the admin create-experiment placeholder which suggested a reserved id. * style: oxfmt wrap long lines after partner/ id rename --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: Christiaan Arnoldus <christiaan@kilocode.ai>
1 parent 251447d commit a45a2c3

10 files changed

Lines changed: 161 additions & 89 deletions

File tree

apps/web/src/app/(app)/claw/hooks/useDefaultModelSelection.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { useEffect, useState } from 'react';
22
import type { ModelOption } from '@/components/shared/ModelCombobox';
3+
import { KILOCLAW_KILO_PROVIDER_PREFIX } from '@/lib/ai-gateway/model-utils';
34

45
export function getDefaultSelectedModel(
56
kilocodeDefaultModel: string | null | undefined,
67
modelOptions: ModelOption[]
78
) {
89
if (modelOptions.length === 0) return '';
9-
if (!kilocodeDefaultModel?.startsWith('kilocode/')) return '';
10+
if (!kilocodeDefaultModel?.startsWith(KILOCLAW_KILO_PROVIDER_PREFIX)) return '';
1011

11-
const defaultModel = kilocodeDefaultModel.replace(/^kilocode\//, '');
12+
const defaultModel = kilocodeDefaultModel.slice(KILOCLAW_KILO_PROVIDER_PREFIX.length);
1213
if (modelOptions.some(model => model.id === defaultModel)) return defaultModel;
1314
return '';
1415
}

apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { CustomLlmDefinitionSchema } from '@kilocode/db/schema-types';
2929
import type { CustomLlmDefinition } from '@kilocode/db/schema-types';
3030
import { deepStrict } from '@/lib/zod/deep-strict';
31+
import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils';
3132
import { toast } from 'sonner';
3233
import { Plus, Pencil } from 'lucide-react';
3334
import Editor from '@monaco-editor/react';
@@ -226,7 +227,7 @@ export function CustomLlmsContent() {
226227
}))
227228
}
228229
disabled={editor.mode === 'edit'}
229-
placeholder="e.g. kilo-internal/my-custom-model"
230+
placeholder={`e.g. ${CUSTOM_LLM_PREFIX}my-custom-model`}
230231
className="font-mono"
231232
/>
232233
</div>

apps/web/src/app/admin/model-experiments/ModelExperimentsContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ function CreateExperimentDialog({
305305
id="exp-public-id"
306306
value={publicModelId}
307307
onChange={e => setPublicModelId(e.target.value)}
308-
placeholder="e.g. kilo/preview-experiment-foo"
308+
placeholder="e.g. partner/preview-experiment-foo"
309309
className="font-mono"
310310
/>
311311
</div>

apps/web/src/lib/ai-gateway/experiments/pick-variant.test.ts

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -82,29 +82,31 @@ async function makeActiveExperiment(opts: {
8282
describe('isPublicIdExperimented', () => {
8383
it('returns false for an unknown public id', async () => {
8484
await clearRoutingCaches();
85-
expect(await isPublicIdExperimented('kilo/preview-not-experimented')).toBe(false);
85+
expect(await isPublicIdExperimented('partner/preview-not-experimented')).toBe(false);
8686
});
8787

8888
redisIt('returns true when the public id has an active experiment', async () => {
89-
await makeActiveExperiment({ publicId: 'kilo/preview-iset-active' });
90-
expect(await seedExperimentedPublicIds(['kilo/preview-iset-active'])).toBe(true);
91-
expect(await isPublicIdExperimented('kilo/preview-iset-active')).toBe(true);
89+
await makeActiveExperiment({ publicId: 'partner/preview-iset-active' });
90+
expect(await seedExperimentedPublicIds(['partner/preview-iset-active'])).toBe(true);
91+
expect(await isPublicIdExperimented('partner/preview-iset-active')).toBe(true);
9292
});
9393

9494
redisIt('returns true when the public id has only a paused experiment', async () => {
95-
const { experimentId } = await makeActiveExperiment({ publicId: 'kilo/preview-iset-paused' });
95+
const { experimentId } = await makeActiveExperiment({
96+
publicId: 'partner/preview-iset-paused',
97+
});
9698
const caller = await createCallerForUser(admin.id);
9799
await caller.admin.modelExperiments.pause({ id: experimentId });
98-
expect(await seedExperimentedPublicIds(['kilo/preview-iset-paused'])).toBe(true);
99-
expect(await isPublicIdExperimented('kilo/preview-iset-paused')).toBe(true);
100+
expect(await seedExperimentedPublicIds(['partner/preview-iset-paused'])).toBe(true);
101+
expect(await isPublicIdExperimented('partner/preview-iset-paused')).toBe(true);
100102
});
101103
});
102104

103105
describe('pickModelExperimentVariant', () => {
104106
it('returns null for a public id with no routing-relevant experiment', async () => {
105107
await clearRoutingCaches();
106108
const result = await pickModelExperimentVariant({
107-
publicModelId: 'kilo/preview-pick-none',
109+
publicModelId: 'partner/preview-pick-none',
108110
userId: 'user-1',
109111
machineId: null,
110112
clientIp: null,
@@ -113,15 +115,15 @@ describe('pickModelExperimentVariant', () => {
113115
});
114116

115117
it('produces stable assignments for the same userId', async () => {
116-
await makeActiveExperiment({ publicId: 'kilo/preview-stable' });
118+
await makeActiveExperiment({ publicId: 'partner/preview-stable' });
117119
const first = await pickModelExperimentVariant({
118-
publicModelId: 'kilo/preview-stable',
120+
publicModelId: 'partner/preview-stable',
119121
userId: 'user-1',
120122
machineId: null,
121123
clientIp: null,
122124
});
123125
const second = await pickModelExperimentVariant({
124-
publicModelId: 'kilo/preview-stable',
126+
publicModelId: 'partner/preview-stable',
125127
userId: 'user-1',
126128
machineId: null,
127129
clientIp: null,
@@ -135,11 +137,11 @@ describe('pickModelExperimentVariant', () => {
135137

136138
it('decrypts and returns the partner-issued api key for the chosen variant', async () => {
137139
const { variantA, variantB } = await makeActiveExperiment({
138-
publicId: 'kilo/preview-key',
140+
publicId: 'partner/preview-key',
139141
apiKeys: ['sk-control-secret', 'sk-treatment-secret'],
140142
});
141143
const result = await pickModelExperimentVariant({
142-
publicModelId: 'kilo/preview-key',
144+
publicModelId: 'partner/preview-key',
143145
userId: 'user-key',
144146
machineId: null,
145147
clientIp: null,
@@ -151,21 +153,21 @@ describe('pickModelExperimentVariant', () => {
151153
});
152154

153155
it('respects allocation-subject precedence: user > machine > ip', async () => {
154-
await makeActiveExperiment({ publicId: 'kilo/preview-alloc' });
156+
await makeActiveExperiment({ publicId: 'partner/preview-alloc' });
155157
const userPick = await pickModelExperimentVariant({
156-
publicModelId: 'kilo/preview-alloc',
158+
publicModelId: 'partner/preview-alloc',
157159
userId: 'user-z',
158160
machineId: 'machine-z',
159161
clientIp: '1.2.3.4',
160162
});
161163
const machinePick = await pickModelExperimentVariant({
162-
publicModelId: 'kilo/preview-alloc',
164+
publicModelId: 'partner/preview-alloc',
163165
userId: null,
164166
machineId: 'machine-z',
165167
clientIp: '1.2.3.4',
166168
});
167169
const ipPick = await pickModelExperimentVariant({
168-
publicModelId: 'kilo/preview-alloc',
170+
publicModelId: 'partner/preview-alloc',
169171
userId: null,
170172
machineId: null,
171173
clientIp: '1.2.3.4',
@@ -182,9 +184,9 @@ describe('pickModelExperimentVariant', () => {
182184
});
183185

184186
it('returns unavailable when no allocation subject is available', async () => {
185-
await makeActiveExperiment({ publicId: 'kilo/preview-noalloc' });
187+
await makeActiveExperiment({ publicId: 'partner/preview-noalloc' });
186188
const result = await pickModelExperimentVariant({
187-
publicModelId: 'kilo/preview-noalloc',
189+
publicModelId: 'partner/preview-noalloc',
188190
userId: null,
189191
machineId: null,
190192
clientIp: null,
@@ -193,12 +195,12 @@ describe('pickModelExperimentVariant', () => {
193195
});
194196

195197
it('returns not-found for a paused experiment so traffic does not silently fall through', async () => {
196-
const { experimentId } = await makeActiveExperiment({ publicId: 'kilo/preview-paused' });
198+
const { experimentId } = await makeActiveExperiment({ publicId: 'partner/preview-paused' });
197199
const caller = await createCallerForUser(admin.id);
198200
await caller.admin.modelExperiments.pause({ id: experimentId });
199201
await clearRoutingCaches();
200202
const result = await pickModelExperimentVariant({
201-
publicModelId: 'kilo/preview-paused',
203+
publicModelId: 'partner/preview-paused',
202204
userId: 'user-q',
203205
machineId: null,
204206
clientIp: null,
@@ -208,11 +210,11 @@ describe('pickModelExperimentVariant', () => {
208210

209211
it('hot-swap: serves the new variant_version_id but keeps the same bucket', async () => {
210212
const { experimentId, variantA, variantB } = await makeActiveExperiment({
211-
publicId: 'kilo/preview-hotswap',
213+
publicId: 'partner/preview-hotswap',
212214
});
213215
const caller = await createCallerForUser(admin.id);
214216
const before = await pickModelExperimentVariant({
215-
publicModelId: 'kilo/preview-hotswap',
217+
publicModelId: 'partner/preview-hotswap',
216218
userId: 'user-pinned',
217219
machineId: null,
218220
clientIp: null,
@@ -229,7 +231,7 @@ describe('pickModelExperimentVariant', () => {
229231
await clearRoutingCaches();
230232

231233
const after = await pickModelExperimentVariant({
232-
publicModelId: 'kilo/preview-hotswap',
234+
publicModelId: 'partner/preview-hotswap',
233235
userId: 'user-pinned',
234236
machineId: null,
235237
clientIp: null,
@@ -249,13 +251,13 @@ describe('pickModelExperimentVariant', () => {
249251
it('weighted distribution lands roughly on configured weights', async () => {
250252
// 1:3 split. With 200 distinct seeds, control should be near 25%.
251253
await makeActiveExperiment({
252-
publicId: 'kilo/preview-weighted',
254+
publicId: 'partner/preview-weighted',
253255
weights: [1, 3],
254256
});
255257
const counts = { control: 0, treatment: 0 };
256258
for (let i = 0; i < 200; i++) {
257259
const r = await pickModelExperimentVariant({
258-
publicModelId: 'kilo/preview-weighted',
260+
publicModelId: 'partner/preview-weighted',
259261
userId: `user-${i}`,
260262
machineId: null,
261263
clientIp: null,
@@ -272,10 +274,10 @@ describe('pickModelExperimentVariant', () => {
272274

273275
it('historical attribution survives hot-swap: old variant_version_id still resolves to old upstream via DB', async () => {
274276
const { experimentId } = await makeActiveExperiment({
275-
publicId: 'kilo/preview-attr',
277+
publicId: 'partner/preview-attr',
276278
});
277279
const before = await pickModelExperimentVariant({
278-
publicModelId: 'kilo/preview-attr',
280+
publicModelId: 'partner/preview-attr',
279281
userId: 'user-attr',
280282
machineId: null,
281283
clientIp: null,
@@ -308,13 +310,13 @@ describe('pickModelExperimentVariant', () => {
308310

309311
it('completed experiments are not returned by the picker (status none after completion)', async () => {
310312
const { experimentId } = await makeActiveExperiment({
311-
publicId: 'kilo/preview-completed',
313+
publicId: 'partner/preview-completed',
312314
});
313315
const caller = await createCallerForUser(admin.id);
314316
await caller.admin.modelExperiments.complete({ id: experimentId });
315317
await clearRoutingCaches();
316318
const result = await pickModelExperimentVariant({
317-
publicModelId: 'kilo/preview-completed',
319+
publicModelId: 'partner/preview-completed',
318320
userId: 'user-c',
319321
machineId: null,
320322
clientIp: null,

apps/web/src/lib/ai-gateway/model-utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
* Keep this file free of server-only dependencies.
44
*/
55

6+
/**
7+
* Public-id namespace prefixes for Kilo-owned models. These are reserved and
8+
* must not be claimed by partner experiment public ids or custom upstreams.
9+
*
10+
* The names look swapped but are intentional: Kilo Code (the extension) selects
11+
* Kilo-hosted models under `kilo/`, while KiloClaw selects them under
12+
* `kilocode/`. `kilo-internal/` is the custom LLM (`custom_llm2`) namespace.
13+
*/
14+
export const KILOCODE_KILO_PROVIDER_PREFIX = 'kilo/';
15+
export const KILOCLAW_KILO_PROVIDER_PREFIX = 'kilocode/';
16+
export const CUSTOM_LLM_PREFIX = 'kilo-internal/';
17+
618
/**
719
* Normalize a model ID by removing the `:free`, `:exacto`, etc. suffixes if present.
820
*/

apps/web/src/lib/ai-gateway/providers/get-provider.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { GatewayRequest } from '@/lib/ai-gateway/providers/openrouter/types';
22
import { shouldRouteToVercel } from '@/lib/ai-gateway/providers/vercel';
33
import { findKiloExclusiveModel, isKiloExclusiveModel } from '@/lib/ai-gateway/models';
4+
import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils';
45
import {
56
getBYOKforOrganization,
67
getBYOKforUser,
@@ -183,8 +184,8 @@ export async function getProvider(input: GetProviderInput): Promise<GetProviderR
183184
const kiloExclusiveModel = findKiloExclusiveModel(requestedModel);
184185

185186
// Model experiment routing for dedicated preview public ids. Runs before
186-
// `kilo-internal/...` and the `kiloExclusiveModels` lookup so an
187-
// experimented public id never falls through to OpenRouter/Vercel.
187+
// the custom-LLM (`kilo-internal/...`) and the `kiloExclusiveModels` lookup
188+
// so an experimented public id never falls through to OpenRouter/Vercel.
188189
const experimented = await isPublicIdExperimented(requestedModel);
189190
if (experimented === true) {
190191
if (kiloExclusiveModel) {
@@ -223,7 +224,7 @@ export async function getProvider(input: GetProviderInput): Promise<GetProviderR
223224
// this id. Fall through to non-experiment routing.
224225
}
225226

226-
if (requestedModel.startsWith('kilo-internal/') && organizationId) {
227+
if (requestedModel.startsWith(CUSTOM_LLM_PREFIX) && organizationId) {
227228
const customLlmResult = await checkCustomLlm(requestedModel, organizationId);
228229
if (customLlmResult) {
229230
return customLlmResult;

apps/web/src/lib/format-model-name.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { KILOCODE_KILO_PROVIDER_PREFIX } from '@/lib/ai-gateway/model-utils';
2+
13
/**
24
* Strips provider prefixes from a model slug to produce a short display name.
35
*
@@ -9,7 +11,9 @@
911
export function formatShortModelName(slug: string): string {
1012
if (!slug) return slug;
1113
// Strip kilo/ prefix first
12-
const withoutKilo = slug.startsWith('kilo/') ? slug.slice(5) : slug;
14+
const withoutKilo = slug.startsWith(KILOCODE_KILO_PROVIDER_PREFIX)
15+
? slug.slice(KILOCODE_KILO_PROVIDER_PREFIX.length)
16+
: slug;
1317
// Strip provider prefix (everything before and including the first /)
1418
const slashIndex = withoutKilo.indexOf('/');
1519
return slashIndex === -1 ? withoutKilo : withoutKilo.slice(slashIndex + 1);

apps/web/src/routers/admin/custom-llm-router.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@ import { CustomLlmDefinitionSchema } from '@kilocode/db/schema-types';
55
import { asc, eq } from 'drizzle-orm';
66
import { TRPCError } from '@trpc/server';
77
import * as z from 'zod';
8-
9-
const KILO_INTERNAL_PREFIX = 'kilo-internal/';
8+
import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils';
109

1110
const publicIdSchema = z
1211
.string()
1312
.min(1, 'public_id is required')
14-
.startsWith(KILO_INTERNAL_PREFIX, `public_id must start with "${KILO_INTERNAL_PREFIX}"`);
13+
.startsWith(CUSTOM_LLM_PREFIX, `public_id must start with "${CUSTOM_LLM_PREFIX}"`);
1514

1615
const UpsertCustomLlmSchema = z.object({
1716
public_id: publicIdSchema,

0 commit comments

Comments
 (0)