From 8dc13f68ed13060c593b90e0da233e71c1375194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:46:28 +0000 Subject: [PATCH 1/4] fix(ai-gateway): enforce bounded opt-out model entries in the admin write schema The admin set-mutation input now applies the same element rule the stored and hot-path readers require, and owns the stored form of the list: trimmed, lowercase, non-empty, length-bounded, capped, and duplicate-free. --- .../src/routers/admin/gateway-config-router.ts | 7 +------ .../src/lib/ai-gateway/gateway-config.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) 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.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(), }); From 4dc39986ddbb7ac44a259b4798fc2ca033de82c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:46:35 +0000 Subject: [PATCH 2/4] fix(admin): surface a rejected routing entry in the gateway form The routing form validates its submission with the write schema before the mutation and renders the field error in the admin validation block; a rejected request shows the server's field message. --- .../src/app/admin/gateway/RoutingContent.tsx | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/admin/gateway/RoutingContent.tsx b/apps/web/src/app/admin/gateway/RoutingContent.tsx index 0251b8e010d..02e54855c83 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) { @@ -113,6 +113,7 @@ export function RoutingContent() { value={optOutModelsValue} onChange={e => { setOptOutModelsValue(e.target.value); + setValidationError(null); setHasChanges(true); }} rows={6} @@ -139,6 +140,15 @@ export function RoutingContent() { /> + {validationError && ( +
+              {validationError}
+            
+ )} +