Skip to content
Merged
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
45 changes: 30 additions & 15 deletions apps/web/src/app/admin/gateway/RoutingContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
DEFAULT_VERCEL_ROUTING_ENABLED,
GatewayConfigInputSchema,
NOTE_MAX_LENGTH,
} from '@kilocode/web-shared/lib/ai-gateway/gateway-config';
import { formatZodError } from '@/lib/zod/format-zod-error';

export function RoutingContent() {
const trpc = useTRPC();
Expand All @@ -24,6 +26,7 @@ export function RoutingContent() {
const [optOutModelsValue, setOptOutModelsValue] = useState('');
const [noteValue, setNoteValue] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);

useEffect(() => {
if (data) {
Expand All @@ -43,7 +46,7 @@ export function RoutingContent() {
toast.success('Gateway routing configuration updated');
},
onError: error => {
toast.error(error.message || 'Failed to update');
toast.error(formatZodError(error));
},
})
);
Expand All @@ -53,23 +56,20 @@ export function RoutingContent() {
return trimmed === '' ? null : trimmed;
}

function optOutModels() {
return [
...new Set(
optOutModelsValue
.split('\n')
.map(model => model.trim())
.filter(Boolean)
),
];
}

function handleSave() {
mutation.mutate({
const parsed = GatewayConfigInputSchema.safeParse({
vercel_routing_enabled: vercelEnabled,
vercel_routing_opt_out_models: optOutModels(),
vercel_routing_opt_out_models: optOutModelsValue
.split('\n')
.filter(model => model.trim() !== ''),
note: noteInput(),
});
if (!parsed.success) {
setValidationError(formatZodError(parsed.error));
return;
}
setValidationError(null);
mutation.mutate(parsed.data);
}

if (isLoading) {
Expand Down Expand Up @@ -108,11 +108,17 @@ export function RoutingContent() {
<Label htmlFor="routing-opt-out-models">Opt-out models</Label>
<Textarea
id="routing-opt-out-models"
aria-describedby="routing-opt-out-models-description"
aria-describedby={
validationError
? 'routing-opt-out-models-description routing-opt-out-models-error'
: 'routing-opt-out-models-description'
}
aria-invalid={validationError ? true : undefined}
placeholder={'moonshotai/kimi-k3\nprovider/another-model'}
value={optOutModelsValue}
onChange={e => {
setOptOutModelsValue(e.target.value);
setValidationError(null);
setHasChanges(true);
}}
rows={6}
Expand All @@ -122,6 +128,15 @@ export function RoutingContent() {
One model ID per line. Matching is exact after entries are lowercased; matching models
are never routed to Vercel. Changes take effect within one minute on warm instances.
</p>
{validationError && (
<pre
id="routing-opt-out-models-error"
className="bg-destructive/10 text-destructive rounded-md p-3 text-sm whitespace-pre-wrap"
role="alert"
>
{validationError}
</pre>
)}
</div>

<div className="flex flex-col gap-2">
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/routers/admin/gateway-config-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from '@jest/globals';
import { cleanupDbForTest, db } from '@kilocode/web-shared/lib/drizzle';
import { ai_gateway_config, type User } from '@kilocode/db/schema';
import { VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH } from '@kilocode/web-shared/lib/ai-gateway/gateway-config';
import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper';
import { createCallerForUser } from '@/routers/test-utils';

let admin: User;

beforeEach(async () => {
await cleanupDbForTest();
admin = await insertTestUser({
google_user_email: `gateway-config-admin-${Math.random()}@admin.example.com`,
is_admin: true,
});
});

const input = {
vercel_routing_enabled: false,
vercel_routing_opt_out_models: [' MoonshotAI/Kimi-K3 ', 'moonshotai/kimi-k3', 'openai/gpt-6'],
note: 'Holding opt-outs',
};

const normalizedOptOutModels = ['moonshotai/kimi-k3', 'openai/gpt-6'];

describe('adminGatewayConfigRouter', () => {
it('stores the normalized opt-out list and reads it back', async () => {
const caller = await createCallerForUser(admin.id);

const saved = await caller.admin.gatewayConfig.set(input);
expect({
vercel_routing_enabled: saved.vercel_routing_enabled,
vercel_routing_opt_out_models: saved.vercel_routing_opt_out_models,
note: saved.note,
}).toEqual({
vercel_routing_enabled: false,
vercel_routing_opt_out_models: normalizedOptOutModels,
note: 'Holding opt-outs',
});

const [row] = await db.select().from(ai_gateway_config);
expect(row.config).toMatchObject({
vercel_routing_enabled: false,
vercel_routing_opt_out_models: normalizedOptOutModels,
note: 'Holding opt-outs',
});

await expect(caller.admin.gatewayConfig.get()).resolves.toMatchObject({
vercel_routing_enabled: false,
vercel_routing_opt_out_models: normalizedOptOutModels,
note: 'Holding opt-outs',
});
});

it('rejects a blank opt-out entry without writing', async () => {
const caller = await createCallerForUser(admin.id);

await expect(
caller.admin.gatewayConfig.set({
...input,
vercel_routing_opt_out_models: ['moonshotai/kimi-k3', ''],
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(await db.select().from(ai_gateway_config)).toEqual([]);
});

it('rejects an over-length opt-out entry without writing', async () => {
const caller = await createCallerForUser(admin.id);

await expect(
caller.admin.gatewayConfig.set({
...input,
vercel_routing_opt_out_models: ['x'.repeat(VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH + 1)],
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(await db.select().from(ai_gateway_config)).toEqual([]);
});
});
7 changes: 1 addition & 6 deletions apps/web/src/routers/admin/gateway-config-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,9 @@ export const adminGatewayConfigRouter = createTRPCRouter({
}),

set: adminProcedure.input(GatewayConfigInputSchema).mutation(async ({ input, ctx }) => {
const optOutModels = [
...new Set(
input.vercel_routing_opt_out_models.map(model => model.trim().toLowerCase()).filter(Boolean)
),
];
const config: GatewayConfig = {
vercel_routing_enabled: input.vercel_routing_enabled,
vercel_routing_opt_out_models: optOutModels,
vercel_routing_opt_out_models: input.vercel_routing_opt_out_models,
updated_at: new Date().toISOString(),
updated_by: ctx.user.id,
updated_by_email: ctx.user.google_user_email,
Expand Down
87 changes: 87 additions & 0 deletions packages/web-shared/src/lib/ai-gateway/gateway-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
GatewayConfigSchema,
GatewayConfigInputSchema,
NOTE_MAX_LENGTH,
VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH,
VERCEL_ROUTING_OPT_OUT_MODELS_MAX_COUNT,
} from './gateway-config';

describe('GatewayRoutingConfigSchema', () => {
Expand Down Expand Up @@ -140,4 +142,89 @@ describe('GatewayConfigInputSchema', () => {
})
).toThrow();
});

test.each(['', ' ', '\t'])('rejects the blank opt-out entry %p', entry => {
expect(() =>
GatewayConfigInputSchema.parse({
vercel_routing_enabled: true,
vercel_routing_opt_out_models: ['moonshotai/kimi-k3', entry],
note: null,
})
).toThrow();
});

test('rejects an opt-out entry longer than the maximum', () => {
expect(() =>
GatewayConfigInputSchema.parse({
vercel_routing_enabled: true,
vercel_routing_opt_out_models: ['x'.repeat(VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH + 1)],
note: null,
})
).toThrow();
});

test('accepts an opt-out entry at the maximum length', () => {
const model = 'x'.repeat(VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH);
expect(
GatewayConfigInputSchema.parse({
vercel_routing_enabled: true,
vercel_routing_opt_out_models: [model],
note: null,
}).vercel_routing_opt_out_models
).toEqual([model]);
});

test('rejects more opt-out entries than the maximum count', () => {
const models = Array.from(
{ length: VERCEL_ROUTING_OPT_OUT_MODELS_MAX_COUNT + 1 },
(_, index) => `provider/model-${index}`
);
expect(() =>
GatewayConfigInputSchema.parse({
vercel_routing_enabled: true,
vercel_routing_opt_out_models: models,
note: null,
})
).toThrow();
});

test('trims, lowercases, and collapses duplicate opt-out entries', () => {
const parsed = GatewayConfigInputSchema.parse({
vercel_routing_enabled: true,
vercel_routing_opt_out_models: [
' MoonshotAI/Kimi-K3 ',
'moonshotai/kimi-k3',
'provider/model-b',
'PROVIDER/MODEL-B',
],
note: null,
});
expect(parsed.vercel_routing_opt_out_models).toEqual([
'moonshotai/kimi-k3',
'provider/model-b',
]);
});

test('round-trips an accepted input through the stored-config and hot-path schemas', () => {
const input = GatewayConfigInputSchema.parse({
vercel_routing_enabled: false,
vercel_routing_opt_out_models: [' MoonshotAI/Kimi-K3 ', 'moonshotai/kimi-k3', 'openai/gpt-6'],
note: 'Holding opt-outs',
});
const storedJson = JSON.stringify({
vercel_routing_enabled: input.vercel_routing_enabled,
vercel_routing_opt_out_models: input.vercel_routing_opt_out_models,
updated_at: '2026-01-01T00:00:00.000Z',
updated_by: 'u1',
updated_by_email: 'a@example.com',
note: input.note,
});

expect(GatewayConfigSchema.parse(JSON.parse(storedJson)).vercel_routing_opt_out_models).toEqual(
['moonshotai/kimi-k3', 'openai/gpt-6']
);
expect(
GatewayRoutingConfigSchema.parse(JSON.parse(storedJson)).vercel_routing_opt_out_models
).toEqual(['moonshotai/kimi-k3', 'openai/gpt-6']);
});
});
16 changes: 15 additions & 1 deletion packages/web-shared/src/lib/ai-gateway/gateway-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export const DEFAULT_VERCEL_ROUTING_ENABLED = true;

export const NOTE_MAX_LENGTH = 500;

export const VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH = 200;
export const VERCEL_ROUTING_OPT_OUT_MODELS_MAX_COUNT = 200;

const note = z.string().max(NOTE_MAX_LENGTH);

/**
Expand Down Expand Up @@ -38,9 +41,20 @@ export const GatewayRoutingConfigSchema = z.object({
vercel_routing_opt_out_models: z.array(z.string().min(1)).default([]),
});

/**
* The admin form submits one model ID per line, so the write schema owns the
* stored form: trimmed, lowercase, non-empty, bounded and duplicate-free. Every
* accepted input therefore stays parseable by the stored-config and hot-path
* schemas, and a saved list never carries an entry the readers reject.
*/
const vercelRoutingOptOutModels = z
.array(z.string().trim().toLowerCase().min(1).max(VERCEL_ROUTING_OPT_OUT_MODEL_MAX_LENGTH))
.max(VERCEL_ROUTING_OPT_OUT_MODELS_MAX_COUNT)
.transform(models => [...new Set(models)]);

/** Schema for the admin set-mutation input. */
export const GatewayConfigInputSchema = z.object({
vercel_routing_enabled: z.boolean(),
vercel_routing_opt_out_models: z.array(z.string()),
vercel_routing_opt_out_models: vercelRoutingOptOutModels,
note: note.nullable(),
});
Loading