diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index 20b2e15c81..d6c336a659 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -3,8 +3,6 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { providerChatCompletionSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; let initialized = false; @@ -30,6 +28,7 @@ export async function OPTIONS() { /** * POST /v1/providers/{provider}/chat/completions * Routes to the specified provider, validating model/provider match. + * Full body format validation is delegated to handleChat. */ export async function POST(request, { params }) { const { provider: rawProvider } = await params; @@ -45,18 +44,25 @@ export async function POST(request, { params }) { await ensureInitialized(); - // Clone request with provider-prefixed model - let rawBody; + // Parse body once so this provider-scoped route can normalize the model prefix + // before delegating full chat-format validation to handleChat. + let rawBody: unknown; try { rawBody = await request.json(); } catch { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); } - const validation = validateBody(providerChatCompletionSchema, rawBody); - if (isValidationFailure(validation)) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message); + + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Request body must be a JSON object"); + } + + const body = rawBody as { model?: string; [key: string]: unknown }; + + // Keep the route-level checks minimal: only guard fields needed for provider prefix handling. + if (body.model !== undefined && typeof body.model !== "string") { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "model must be a string"); } - const body = validation.data; // Validate model belongs to this provider if (body.model) { diff --git a/tests/unit/provider-scoped-chat-completions-validation.test.ts b/tests/unit/provider-scoped-chat-completions-validation.test.ts new file mode 100644 index 0000000000..24650b5f23 --- /dev/null +++ b/tests/unit/provider-scoped-chat-completions-validation.test.ts @@ -0,0 +1,86 @@ +// Regression guard for #5907 — the provider-scoped chat/completions route +// (`/v1/providers/{provider}/chat/completions`) must NOT re-apply the strict +// `providerChatCompletionSchema`. Full body-format validation is delegated to +// handleChat; the route keeps only the minimal guards it needs to normalize the +// model prefix. This test locks that contract without mocking handleChat (the +// project's node:test runner does not enable --experimental-test-module-mocks, +// and the Stryker tap-runner rejects mock.module), by exercising the branches +// that return BEFORE delegation: +// - a loosely-valid body (no `messages`, which the removed strict schema would +// have 400'd) now reaches the model-prefix logic instead of a schema 400; +// - the minimal route-level guards still reject invalid JSON, non-object +// bodies, non-string models, and unknown providers. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +const { POST } = + await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts"); + +// Importing the route transitively opens the SQLite handle (handleChat's graph). +// Release it so Node's native test runner does not hang on open handles. +after(async () => { + try { + const core = await import("../../src/lib/db/core.ts"); + core.resetDbInstance(); + } catch { + // best-effort cleanup — never fail the suite on teardown + } +}); + +function makeRequest(body: string) { + return new Request("http://localhost/v1/providers/openai/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); +} + +const params = (provider: string) => ({ params: Promise.resolve({ provider }) }); + +test("#5907 a loosely-valid body (no messages) is no longer rejected by the removed strict schema", async () => { + // `{ model: "anthropic/..." }` has NO `messages`. Under the old strict + // providerChatCompletionSchema this 400'd at the route before any prefix + // check. The relaxed route must instead run the model-prefix validation and + // return the *specific* cross-provider error — proving the schema is gone. + const res = await POST( + makeRequest(JSON.stringify({ model: "anthropic/claude-3-5-sonnet" })), + params("openai") + ); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match( + body.error.message, + /does not belong to provider/i, + "expected the model-prefix check to run — a schema validation 400 would mean the strict schema is still applied" + ); +}); + +test("#5907 rejects invalid JSON with 400", async () => { + const res = await POST(makeRequest("{not json"), params("openai")); + assert.equal(res.status, 400); +}); + +test("#5907 rejects a non-object body (array) with 400", async () => { + const res = await POST(makeRequest(JSON.stringify([1, 2, 3])), params("openai")); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error.message, /must be a JSON object/i); +}); + +test("#5907 rejects a non-string model with 400", async () => { + const res = await POST(makeRequest(JSON.stringify({ model: 123 })), params("openai")); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error.message, /model must be a string/i); +}); + +test("#5907 rejects an unknown provider with 400", async () => { + const res = await POST(makeRequest(JSON.stringify({ model: "gpt-4o" })), params("nope-xyz")); + assert.equal(res.status, 400); +}); + +test("#5907 route errors are sanitized (no stack trace leak in body)", async () => { + const res = await POST(makeRequest("{bad"), params("openai")); + const body = await res.json(); + assert.ok(!JSON.stringify(body).includes("at /"), "error body must not leak a stack trace"); +});