diff --git a/apps/web/src/app/admin/gateway/RoutingContent.tsx b/apps/web/src/app/admin/gateway/RoutingContent.tsx
index 0251b8e010d..cb5a24db618 100644
--- a/apps/web/src/app/admin/gateway/RoutingContent.tsx
+++ b/apps/web/src/app/admin/gateway/RoutingContent.tsx
@@ -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();
@@ -24,6 +26,7 @@ export function RoutingContent() {
const [optOutModelsValue, setOptOutModelsValue] = useState('');
const [noteValue, setNoteValue] = useState('');
const [hasChanges, setHasChanges] = useState(false);
+ const [validationError, setValidationError] = useState(null);
useEffect(() => {
if (data) {
@@ -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));
},
})
);
@@ -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) {
@@ -108,11 +108,17 @@ export function RoutingContent() {
+ {validationError && (
+
+ {validationError}
+
+ )}
diff --git a/apps/web/src/routers/admin/gateway-config-router.test.ts b/apps/web/src/routers/admin/gateway-config-router.test.ts
new file mode 100644
index 00000000000..ffe5b18927d
--- /dev/null
+++ b/apps/web/src/routers/admin/gateway-config-router.test.ts
@@ -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([]);
+ });
+});
diff --git a/apps/web/src/routers/admin/gateway-config-router.ts b/apps/web/src/routers/admin/gateway-config-router.ts
index 659799eb4c2..25d4d649f18 100644
--- a/apps/web/src/routers/admin/gateway-config-router.ts
+++ b/apps/web/src/routers/admin/gateway-config-router.ts
@@ -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,
diff --git a/packages/web-shared/src/lib/ai-gateway/gateway-config.test.ts b/packages/web-shared/src/lib/ai-gateway/gateway-config.test.ts
index 727336e87b9..8a0dc9386ae 100644
--- a/packages/web-shared/src/lib/ai-gateway/gateway-config.test.ts
+++ b/packages/web-shared/src/lib/ai-gateway/gateway-config.test.ts
@@ -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', () => {
@@ -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']);
+ });
});
diff --git a/packages/web-shared/src/lib/ai-gateway/gateway-config.ts b/packages/web-shared/src/lib/ai-gateway/gateway-config.ts
index 2aaa8d31245..8626fb477c9 100644
--- a/packages/web-shared/src/lib/ai-gateway/gateway-config.ts
+++ b/packages/web-shared/src/lib/ai-gateway/gateway-config.ts
@@ -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);
/**
@@ -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(),
});