From 8642e2b7219dc0567e30ab6265b151cc1394116a Mon Sep 17 00:00:00 2001 From: Kfir Amar Date: Sun, 15 Mar 2026 18:25:22 +0200 Subject: [PATCH 1/4] fix(codex): preserve native responses payloads --- open-sse/executors/codex.ts | 21 ++++-- open-sse/handlers/chatCore.ts | 118 ++++++++++++++++++++-------------- tests/unit/plan3-p0.test.mjs | 66 +++++++++++++++++++ 3 files changed, 151 insertions(+), 54 deletions(-) diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 55a6df207c..6b8a0f5460 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -106,8 +106,22 @@ export class CodexExecutor extends BaseExecutor { * Transform request before sending - inject default instructions if missing */ transformRequest(model, body, stream, credentials) { + const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; + // Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed. body.stream = true; + delete body._nativeCodexPassthrough; + + const requestServiceTier = normalizeServiceTierValue(body.service_tier); + if (requestServiceTier) { + body.service_tier = requestServiceTier; + } else if (defaultFastServiceTierEnabled) { + body.service_tier = CODEX_FAST_WIRE_VALUE; + } + + if (nativeCodexPassthrough) { + return body; + } // If no instructions provided, inject default Codex instructions if (!body.instructions || body.instructions.trim() === "") { @@ -117,13 +131,6 @@ export class CodexExecutor extends BaseExecutor { // Ensure store is false (Codex requirement) body.store = false; - const requestServiceTier = normalizeServiceTierValue(body.service_tier); - if (requestServiceTier) { - body.service_tier = requestServiceTier; - } else if (defaultFastServiceTierEnabled) { - body.service_tier = CODEX_FAST_WIRE_VALUE; - } - // Extract thinking level from model name suffix // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) const effortLevels = ["none", "low", "medium", "high", "xhigh"]; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5661765ebd..8d07286cd0 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -42,6 +42,20 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts"; import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts"; +export function shouldUseNativeCodexPassthrough({ + provider, + sourceFormat, + endpointPath, +}: { + provider?: string | null; + sourceFormat?: string | null; + endpointPath?: string | null; +}): boolean { + if (provider !== "codex") return false; + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; + return String(endpointPath || "").toLowerCase().endsWith("/responses"); +} + /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -103,6 +117,11 @@ export async function handleChatCore({ const sourceFormat = detectFormat(body); const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase(); const isResponsesEndpoint = endpointPath.endsWith("/responses"); + const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({ + provider, + sourceFormat, + endpointPath, + }); // Check for bypass patterns (warmup, skip) - return fake response const bypassResponse = handleBypassRequest(body, model, userAgent); @@ -164,55 +183,60 @@ export async function handleChatCore({ // Translate request (pass reqLogger for intermediate logging) let translatedBody = body; try { - // Issue #199: Disable tool name prefix when routing Claude-format requests - // to non-Claude backends (prefix causes tool name mismatches) - const claudeProviders = ["claude", "anthropic"]; - if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) { - translatedBody = { ...translatedBody, _disableToolPrefix: true }; - } + if (nativeCodexPassthrough) { + translatedBody = { ...translatedBody, _nativeCodexPassthrough: true }; + log?.debug?.("FORMAT", "native codex passthrough enabled"); + } else { + // Issue #199: Disable tool name prefix when routing Claude-format requests + // to non-Claude backends (prefix causes tool name mismatches) + const claudeProviders = ["claude", "anthropic"]; + if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) { + translatedBody = { ...translatedBody, _disableToolPrefix: true }; + } - // ── #291: Strip empty name fields from messages/input items ── - // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. - // Clients like PocketPaw may forward empty name fields from assistant turns. - if (Array.isArray(body.messages)) { - body.messages = body.messages.map((msg: Record) => { - if (msg.name === "") { - const { name: _n, ...rest } = msg; - return rest; - } - return msg; - }); - } - if (Array.isArray(body.input)) { - body.input = body.input.map((item: Record) => { - if (item.name === "") { - const { name: _n, ...rest } = item; - return rest; - } - return item; - }); - } - // ── #346: Strip tools with empty function.name ── - // Claude Code sometimes forwards tool definitions with empty names, causing - // OpenAI-compatible upstream providers to reject with: - // "Invalid 'input[N].name': empty string. Expected minimum length 1." - if (Array.isArray(body.tools)) { - body.tools = body.tools.filter((tool: Record) => { - const fn = tool.function as Record | undefined; - return fn?.name && String(fn.name).trim().length > 0; - }); - } + // ── #291: Strip empty name fields from messages/input items ── + // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. + // Clients like PocketPaw may forward empty name fields from assistant turns. + if (Array.isArray(body.messages)) { + body.messages = body.messages.map((msg: Record) => { + if (msg.name === "") { + const { name: _n, ...rest } = msg; + return rest; + } + return msg; + }); + } + if (Array.isArray(body.input)) { + body.input = body.input.map((item: Record) => { + if (item.name === "") { + const { name: _n, ...rest } = item; + return rest; + } + return item; + }); + } + // ── #346: Strip tools with empty function.name ── + // Claude Code sometimes forwards tool definitions with empty names, causing + // OpenAI-compatible upstream providers to reject with: + // "Invalid 'input[N].name': empty string. Expected minimum length 1." + if (Array.isArray(body.tools)) { + body.tools = body.tools.filter((tool: Record) => { + const fn = tool.function as Record | undefined; + return fn?.name && String(fn.name).trim().length > 0; + }); + } - translatedBody = translateRequest( - sourceFormat, - targetFormat, - model, - translatedBody, - stream, - credentials, - provider, - reqLogger - ); + translatedBody = translateRequest( + sourceFormat, + targetFormat, + model, + translatedBody, + stream, + credentials, + provider, + reqLogger + ); + } } catch (error) { const parsedStatus = Number(error?.statusCode); const statusCode = diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index 379f534069..49e97b9b77 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { FORMATS } from "../../open-sse/translator/formats.ts"; import { getModelInfoCore } from "../../open-sse/services/model.ts"; import { detectFormat } from "../../open-sse/services/provider.ts"; +import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore.ts"; import { translateRequest } from "../../open-sse/translator/index.ts"; import { GithubExecutor } from "../../open-sse/executors/github.ts"; import { @@ -79,6 +80,44 @@ test("CodexExecutor maps fast service tier to priority", () => { assert.equal(transformed.service_tier, "priority"); }); +test("shouldUseNativeCodexPassthrough only enables responses-native Codex requests", () => { + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/responses", + }), + true + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI, + endpointPath: "/v1/responses", + }), + false + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "openai", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/responses", + }), + false + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/chat/completions", + }), + false + ); +}); + test("CodexExecutor can force fast service tier from settings", () => { setDefaultFastServiceTierEnabled(true); @@ -101,6 +140,33 @@ test("CodexExecutor always requests SSE accept header", () => { assert.equal(headers.Accept, "text/event-stream"); }); +test("CodexExecutor preserves native responses payloads for Codex passthrough", () => { + const executor = new CodexExecutor(); + const transformed = executor.transformRequest( + "gpt-5.1-codex", + { + model: "gpt-5.1-codex", + input: "ship it", + instructions: "custom system prompt", + store: true, + metadata: { source: "codex-client" }, + reasoning_effort: "high", + service_tier: "fast", + _nativeCodexPassthrough: true, + stream: false, + }, + false + ); + + assert.equal(transformed.stream, true); + assert.equal(transformed.service_tier, "priority"); + assert.equal(transformed.instructions, "custom system prompt"); + assert.equal(transformed.store, true); + assert.deepEqual(transformed.metadata, { source: "codex-client" }); + assert.equal(transformed.reasoning_effort, "high"); + assert.ok(!("_nativeCodexPassthrough" in transformed)); +}); + test("translateNonStreamingResponse converts Responses API payload to OpenAI chat.completion", () => { const responseBody = { id: "resp_123", From b1c713de6077da9d137baac321bdeb3201160d89 Mon Sep 17 00:00:00 2001 From: Kfir Amar Date: Sun, 15 Mar 2026 18:35:43 +0200 Subject: [PATCH 2/4] fix(codex): avoid mutating request body --- open-sse/handlers/chatCore.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8d07286cd0..f940984f82 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -184,21 +184,23 @@ export async function handleChatCore({ let translatedBody = body; try { if (nativeCodexPassthrough) { - translatedBody = { ...translatedBody, _nativeCodexPassthrough: true }; + translatedBody = { ...body, _nativeCodexPassthrough: true }; log?.debug?.("FORMAT", "native codex passthrough enabled"); } else { + translatedBody = { ...body }; + // Issue #199: Disable tool name prefix when routing Claude-format requests // to non-Claude backends (prefix causes tool name mismatches) const claudeProviders = ["claude", "anthropic"]; if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) { - translatedBody = { ...translatedBody, _disableToolPrefix: true }; + translatedBody._disableToolPrefix = true; } // ── #291: Strip empty name fields from messages/input items ── // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. // Clients like PocketPaw may forward empty name fields from assistant turns. - if (Array.isArray(body.messages)) { - body.messages = body.messages.map((msg: Record) => { + if (Array.isArray(translatedBody.messages)) { + translatedBody.messages = translatedBody.messages.map((msg: Record) => { if (msg.name === "") { const { name: _n, ...rest } = msg; return rest; @@ -206,8 +208,8 @@ export async function handleChatCore({ return msg; }); } - if (Array.isArray(body.input)) { - body.input = body.input.map((item: Record) => { + if (Array.isArray(translatedBody.input)) { + translatedBody.input = translatedBody.input.map((item: Record) => { if (item.name === "") { const { name: _n, ...rest } = item; return rest; @@ -219,8 +221,8 @@ export async function handleChatCore({ // Claude Code sometimes forwards tool definitions with empty names, causing // OpenAI-compatible upstream providers to reject with: // "Invalid 'input[N].name': empty string. Expected minimum length 1." - if (Array.isArray(body.tools)) { - body.tools = body.tools.filter((tool: Record) => { + if (Array.isArray(translatedBody.tools)) { + translatedBody.tools = translatedBody.tools.filter((tool: Record) => { const fn = tool.function as Record | undefined; return fn?.name && String(fn.name).trim().length > 0; }); From fc2af8ba87dda8497fd5dadee4e75f153f5beef6 Mon Sep 17 00:00:00 2001 From: Kfir Amar Date: Sun, 15 Mar 2026 19:33:35 +0200 Subject: [PATCH 3/4] fix(api): validate pricing sync and task routing routes --- src/app/api/pricing/sync/route.ts | 27 ++++++++--- src/app/api/settings/task-routing/route.ts | 46 +++++++++++++++---- src/shared/validation/schemas.ts | 52 ++++++++++++++++++++++ tests/unit/t06-schema-hardening.test.mjs | 49 ++++++++++++++++++++ 4 files changed, 161 insertions(+), 13 deletions(-) diff --git a/src/app/api/pricing/sync/route.ts b/src/app/api/pricing/sync/route.ts index b987ce3c3f..b60d78fccf 100644 --- a/src/app/api/pricing/sync/route.ts +++ b/src/app/api/pricing/sync/route.ts @@ -7,14 +7,31 @@ */ import { NextRequest, NextResponse } from "next/server"; +import { pricingSyncRequestSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function POST(request: NextRequest) { + let rawBody: unknown; try { - const body = await request.json().catch(() => ({})); - const sources = Array.isArray(body.sources) - ? body.sources.filter((s: unknown): s is string => typeof s === "string") - : undefined; - const dryRun = body.dryRun === true; + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(pricingSyncRequestSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { sources, dryRun = false } = validation.data; const { syncPricingFromSources } = await import("@/lib/pricingSync"); const result = await syncPricingFromSources({ sources, dryRun }); diff --git a/src/app/api/settings/task-routing/route.ts b/src/app/api/settings/task-routing/route.ts index c8d346f434..d86fbc2c7b 100644 --- a/src/app/api/settings/task-routing/route.ts +++ b/src/app/api/settings/task-routing/route.ts @@ -6,6 +6,8 @@ import { getDefaultTaskModelMap, } from "@omniroute/open-sse/services/taskAwareRouter.ts"; import { updateSettings } from "@/lib/db/settings"; +import { taskRoutingActionSchema, updateTaskRoutingSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; /** * GET /api/settings/task-routing @@ -29,15 +31,29 @@ export async function GET() { * Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean } */ export async function PUT(request: Request) { - let rawBody: Record; + let rawBody: unknown; try { rawBody = await request.json(); } catch { - return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); } try { - setTaskRoutingConfig(rawBody as any); + const validation = validateBody(updateTaskRoutingSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const config = validation.data; + + setTaskRoutingConfig(config); // Persist to database (excluding stats) const { stats, ...persistable } = getTaskRoutingConfig(); @@ -56,15 +72,29 @@ export async function PUT(request: Request) { * For "detect": pass { action: "detect", body: } to test detection */ export async function POST(request: Request) { - let rawBody: any; + let rawBody: unknown; try { rawBody = await request.json(); } catch { - return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); } try { - if (rawBody.action === "reset-stats") { + const validation = validateBody(taskRoutingActionSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const actionRequest = validation.data; + + if (actionRequest.action === "reset-stats") { resetTaskRoutingStats(); return NextResponse.json({ success: true, @@ -72,9 +102,9 @@ export async function POST(request: Request) { }); } - if (rawBody.action === "detect") { + if (actionRequest.action === "detect") { const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts"); - const taskType = detectTaskType(rawBody.body || {}); + const taskType = detectTaskType(actionRequest.body || {}); const config = getTaskRoutingConfig(); return NextResponse.json({ taskType, diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index c2099350f6..09cb5abd72 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -378,6 +378,58 @@ export const resetStatsActionSchema = z.object({ action: z.literal("reset-stats"), }); +const pricingSyncSourceSchema = z.enum(["litellm"]); + +export const pricingSyncRequestSchema = z + .object({ + sources: z.array(pricingSyncSourceSchema).min(1).optional(), + dryRun: z.boolean().optional(), + }) + .strict(); + +const taskRoutingModelMapSchema = z + .object({ + coding: z.string().max(200).optional(), + creative: z.string().max(200).optional(), + analysis: z.string().max(200).optional(), + vision: z.string().max(200).optional(), + summarization: z.string().max(200).optional(), + background: z.string().max(200).optional(), + chat: z.string().max(200).optional(), + }) + .strict(); + +export const updateTaskRoutingSchema = z + .object({ + enabled: z.boolean().optional(), + taskModelMap: taskRoutingModelMapSchema.optional(), + detectionEnabled: z.boolean().optional(), + }) + .strict() + .superRefine((value, ctx) => { + if ( + value.enabled === undefined && + value.taskModelMap === undefined && + value.detectionEnabled === undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "No valid fields to update", + path: [], + }); + } + }); + +export const taskRoutingActionSchema = z.discriminatedUnion("action", [ + resetStatsActionSchema, + z + .object({ + action: z.literal("detect"), + body: jsonObjectSchema.optional(), + }) + .strict(), +]); + export const updateComboDefaultsSchema = z .object({ comboDefaults: comboRuntimeConfigSchema.optional(), diff --git a/tests/unit/t06-schema-hardening.test.mjs b/tests/unit/t06-schema-hardening.test.mjs index 9c246a3ac3..6140cddaa1 100644 --- a/tests/unit/t06-schema-hardening.test.mjs +++ b/tests/unit/t06-schema-hardening.test.mjs @@ -10,6 +10,9 @@ import { v1EmbeddingsSchema, providerChatCompletionSchema, v1CountTokensSchema, + pricingSyncRequestSchema, + updateTaskRoutingSchema, + taskRoutingActionSchema, } from "../../src/shared/validation/schemas.ts"; test("translatorDetectSchema rejects empty body object", () => { @@ -130,3 +133,49 @@ test("v1CountTokensSchema rejects empty messages", () => { }); assert.equal(validation.success, false); }); + +test("pricingSyncRequestSchema rejects unsupported sources", () => { + const validation = validateBody(pricingSyncRequestSchema, { + sources: ["unknown-source"], + }); + assert.equal(validation.success, false); +}); + +test("pricingSyncRequestSchema accepts dryRun-only requests", () => { + const validation = validateBody(pricingSyncRequestSchema, { + dryRun: true, + }); + assert.equal(validation.success, true); +}); + +test("updateTaskRoutingSchema rejects empty payloads", () => { + const validation = validateBody(updateTaskRoutingSchema, {}); + assert.equal(validation.success, false); +}); + +test("updateTaskRoutingSchema accepts partial task routing updates", () => { + const validation = validateBody(updateTaskRoutingSchema, { + enabled: true, + taskModelMap: { + coding: "codex/gpt-5.1-codex", + }, + }); + assert.equal(validation.success, true); +}); + +test("taskRoutingActionSchema rejects unknown actions", () => { + const validation = validateBody(taskRoutingActionSchema, { + action: "noop", + }); + assert.equal(validation.success, false); +}); + +test("taskRoutingActionSchema accepts detect action with object body", () => { + const validation = validateBody(taskRoutingActionSchema, { + action: "detect", + body: { + messages: [{ role: "user", content: "write code" }], + }, + }); + assert.equal(validation.success, true); +}); From 271f5f9c644478cfb1bd818e7227c4c6d3ef9ca3 Mon Sep 17 00:00:00 2001 From: Kfir Amar Date: Sun, 15 Mar 2026 20:37:18 +0200 Subject: [PATCH 4/4] Revert "fix(api): validate pricing sync and task routing routes" This reverts commit fc2af8ba87dda8497fd5dadee4e75f153f5beef6. --- src/app/api/pricing/sync/route.ts | 27 +++-------- src/app/api/settings/task-routing/route.ts | 46 ++++--------------- src/shared/validation/schemas.ts | 52 ---------------------- tests/unit/t06-schema-hardening.test.mjs | 49 -------------------- 4 files changed, 13 insertions(+), 161 deletions(-) diff --git a/src/app/api/pricing/sync/route.ts b/src/app/api/pricing/sync/route.ts index b60d78fccf..b987ce3c3f 100644 --- a/src/app/api/pricing/sync/route.ts +++ b/src/app/api/pricing/sync/route.ts @@ -7,31 +7,14 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { pricingSyncRequestSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function POST(request: NextRequest) { - let rawBody: unknown; try { - rawBody = await request.json(); - } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); - } - - try { - const validation = validateBody(pricingSyncRequestSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - const { sources, dryRun = false } = validation.data; + const body = await request.json().catch(() => ({})); + const sources = Array.isArray(body.sources) + ? body.sources.filter((s: unknown): s is string => typeof s === "string") + : undefined; + const dryRun = body.dryRun === true; const { syncPricingFromSources } = await import("@/lib/pricingSync"); const result = await syncPricingFromSources({ sources, dryRun }); diff --git a/src/app/api/settings/task-routing/route.ts b/src/app/api/settings/task-routing/route.ts index d86fbc2c7b..c8d346f434 100644 --- a/src/app/api/settings/task-routing/route.ts +++ b/src/app/api/settings/task-routing/route.ts @@ -6,8 +6,6 @@ import { getDefaultTaskModelMap, } from "@omniroute/open-sse/services/taskAwareRouter.ts"; import { updateSettings } from "@/lib/db/settings"; -import { taskRoutingActionSchema, updateTaskRoutingSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; /** * GET /api/settings/task-routing @@ -31,29 +29,15 @@ export async function GET() { * Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean } */ export async function PUT(request: Request) { - let rawBody: unknown; + let rawBody: Record; try { rawBody = await request.json(); } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); } try { - const validation = validateBody(updateTaskRoutingSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - const config = validation.data; - - setTaskRoutingConfig(config); + setTaskRoutingConfig(rawBody as any); // Persist to database (excluding stats) const { stats, ...persistable } = getTaskRoutingConfig(); @@ -72,29 +56,15 @@ export async function PUT(request: Request) { * For "detect": pass { action: "detect", body: } to test detection */ export async function POST(request: Request) { - let rawBody: unknown; + let rawBody: any; try { rawBody = await request.json(); } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); } try { - const validation = validateBody(taskRoutingActionSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - const actionRequest = validation.data; - - if (actionRequest.action === "reset-stats") { + if (rawBody.action === "reset-stats") { resetTaskRoutingStats(); return NextResponse.json({ success: true, @@ -102,9 +72,9 @@ export async function POST(request: Request) { }); } - if (actionRequest.action === "detect") { + if (rawBody.action === "detect") { const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts"); - const taskType = detectTaskType(actionRequest.body || {}); + const taskType = detectTaskType(rawBody.body || {}); const config = getTaskRoutingConfig(); return NextResponse.json({ taskType, diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 09cb5abd72..c2099350f6 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -378,58 +378,6 @@ export const resetStatsActionSchema = z.object({ action: z.literal("reset-stats"), }); -const pricingSyncSourceSchema = z.enum(["litellm"]); - -export const pricingSyncRequestSchema = z - .object({ - sources: z.array(pricingSyncSourceSchema).min(1).optional(), - dryRun: z.boolean().optional(), - }) - .strict(); - -const taskRoutingModelMapSchema = z - .object({ - coding: z.string().max(200).optional(), - creative: z.string().max(200).optional(), - analysis: z.string().max(200).optional(), - vision: z.string().max(200).optional(), - summarization: z.string().max(200).optional(), - background: z.string().max(200).optional(), - chat: z.string().max(200).optional(), - }) - .strict(); - -export const updateTaskRoutingSchema = z - .object({ - enabled: z.boolean().optional(), - taskModelMap: taskRoutingModelMapSchema.optional(), - detectionEnabled: z.boolean().optional(), - }) - .strict() - .superRefine((value, ctx) => { - if ( - value.enabled === undefined && - value.taskModelMap === undefined && - value.detectionEnabled === undefined - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "No valid fields to update", - path: [], - }); - } - }); - -export const taskRoutingActionSchema = z.discriminatedUnion("action", [ - resetStatsActionSchema, - z - .object({ - action: z.literal("detect"), - body: jsonObjectSchema.optional(), - }) - .strict(), -]); - export const updateComboDefaultsSchema = z .object({ comboDefaults: comboRuntimeConfigSchema.optional(), diff --git a/tests/unit/t06-schema-hardening.test.mjs b/tests/unit/t06-schema-hardening.test.mjs index 6140cddaa1..9c246a3ac3 100644 --- a/tests/unit/t06-schema-hardening.test.mjs +++ b/tests/unit/t06-schema-hardening.test.mjs @@ -10,9 +10,6 @@ import { v1EmbeddingsSchema, providerChatCompletionSchema, v1CountTokensSchema, - pricingSyncRequestSchema, - updateTaskRoutingSchema, - taskRoutingActionSchema, } from "../../src/shared/validation/schemas.ts"; test("translatorDetectSchema rejects empty body object", () => { @@ -133,49 +130,3 @@ test("v1CountTokensSchema rejects empty messages", () => { }); assert.equal(validation.success, false); }); - -test("pricingSyncRequestSchema rejects unsupported sources", () => { - const validation = validateBody(pricingSyncRequestSchema, { - sources: ["unknown-source"], - }); - assert.equal(validation.success, false); -}); - -test("pricingSyncRequestSchema accepts dryRun-only requests", () => { - const validation = validateBody(pricingSyncRequestSchema, { - dryRun: true, - }); - assert.equal(validation.success, true); -}); - -test("updateTaskRoutingSchema rejects empty payloads", () => { - const validation = validateBody(updateTaskRoutingSchema, {}); - assert.equal(validation.success, false); -}); - -test("updateTaskRoutingSchema accepts partial task routing updates", () => { - const validation = validateBody(updateTaskRoutingSchema, { - enabled: true, - taskModelMap: { - coding: "codex/gpt-5.1-codex", - }, - }); - assert.equal(validation.success, true); -}); - -test("taskRoutingActionSchema rejects unknown actions", () => { - const validation = validateBody(taskRoutingActionSchema, { - action: "noop", - }); - assert.equal(validation.success, false); -}); - -test("taskRoutingActionSchema accepts detect action with object body", () => { - const validation = validateBody(taskRoutingActionSchema, { - action: "detect", - body: { - messages: [{ role: "user", content: "write code" }], - }, - }); - assert.equal(validation.success, true); -});