mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(api): return 415 on /v1/messages for non-JSON Content-Type via requireJsonContentType middleware (#6414) Integrated into release/v3.8.47. (thanks @chirag127)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import { requireJsonContentType } from "@/shared/middleware/requireJsonContentType";
|
||||
import {
|
||||
withEarlyStreamKeepalive,
|
||||
ANTHROPIC_PING_FRAME,
|
||||
@@ -39,6 +40,11 @@ export async function OPTIONS() {
|
||||
* parsed at most once per request.
|
||||
*/
|
||||
async function postHandler(request: any, context: any, preParsedBody: any = null) {
|
||||
// Reject non-JSON Content-Type with 415 before touching the body — mirrors OpenAI's
|
||||
// reference API and matches /v1/chat/completions (#6414).
|
||||
const ctRejection = requireJsonContentType(request);
|
||||
if (ctRejection) return ctRejection;
|
||||
|
||||
await ensureInitialized();
|
||||
// Streaming Anthropic clients (Claude Code, the Anthropic SDK) drop the connection
|
||||
// when no bytes arrive while a large prompt is processed before the first token — a
|
||||
|
||||
47
src/shared/middleware/requireJsonContentType.ts
Normal file
47
src/shared/middleware/requireJsonContentType.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Content-Type guard for chat/message routes (#6414).
|
||||
*
|
||||
* OpenAI's reference API returns HTTP 415 `unsupported_media_type` when a POST to
|
||||
* /v1/chat/completions arrives with a non-JSON Content-Type (or none). OmniRoute
|
||||
* previously admitted such requests, silently parsed the body as JSON (via
|
||||
* `request.clone().json().catch(() => null)`), and let them fall through to the
|
||||
* provider-lookup layer — where they emerged as a misleading `model_not_found` /
|
||||
* generic error rather than the RFC 7231 §6.5.13-mandated 415. This guard closes
|
||||
* that gap at the route boundary, mirroring the pre-parse pattern already used by
|
||||
* `chatBodyAdmission.ts`.
|
||||
*
|
||||
* Returns a ready 415 Response when the request must be rejected, or `null` to
|
||||
* proceed. Only inspects the header — no body read, no I/O.
|
||||
*
|
||||
* @module shared/middleware/requireJsonContentType
|
||||
*/
|
||||
|
||||
import { CORS_HEADERS } from "../utils/cors";
|
||||
|
||||
/**
|
||||
* Route-level guard. Rejects POST/PUT/PATCH requests whose `Content-Type` is not
|
||||
* `application/json` (a `; charset=…` suffix is permitted). A missing header is
|
||||
* treated as unsupported, matching the OpenAI reference behavior cited in #6414.
|
||||
*/
|
||||
export function requireJsonContentType(request: Request): Response | null {
|
||||
const method = request.method.toUpperCase();
|
||||
if (method !== "POST" && method !== "PUT" && method !== "PATCH") return null;
|
||||
|
||||
const raw = request.headers.get("content-type");
|
||||
const ct = (raw ?? "").trim().toLowerCase();
|
||||
if (ct.startsWith("application/json")) return null;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Content-Type must be application/json",
|
||||
type: "invalid_request_error",
|
||||
code: "unsupported_media_type",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 415,
|
||||
headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
89
tests/unit/chat-content-type-6414.test.ts
Normal file
89
tests/unit/chat-content-type-6414.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// #6414: POST /v1/chat/completions (and /v1/messages) must return HTTP 415
|
||||
// `unsupported_media_type` when the Content-Type header is not application/json,
|
||||
// matching OpenAI's reference API and RFC 7231 §6.5.13. Previously OmniRoute
|
||||
// silently parsed such requests as JSON via `.clone().json().catch(() => null)`
|
||||
// and let them reach the provider-lookup layer, where they surfaced as misleading
|
||||
// `model_not_found` / generic errors instead of a boundary 415.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { requireJsonContentType } = await import(
|
||||
"../../src/shared/middleware/requireJsonContentType.ts"
|
||||
);
|
||||
|
||||
function makeRequest(method: string, contentType: string | null): Request {
|
||||
const headers = new Headers();
|
||||
if (contentType !== null) headers.set("content-type", contentType);
|
||||
return new Request("http://localhost/v1/chat/completions", {
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" || method === "HEAD" ? undefined : "{}",
|
||||
});
|
||||
}
|
||||
|
||||
test("returns 415 for text/plain on POST", async () => {
|
||||
const rejection = requireJsonContentType(makeRequest("POST", "text/plain"));
|
||||
assert.ok(rejection, "expected a rejection Response");
|
||||
assert.equal(rejection.status, 415);
|
||||
const body = await rejection.json();
|
||||
assert.equal(body.error.code, "unsupported_media_type");
|
||||
assert.equal(body.error.type, "invalid_request_error");
|
||||
});
|
||||
|
||||
test("returns 415 when Content-Type header is missing on POST", async () => {
|
||||
const rejection = requireJsonContentType(makeRequest("POST", null));
|
||||
assert.ok(rejection, "missing content-type must be rejected");
|
||||
assert.equal(rejection.status, 415);
|
||||
});
|
||||
|
||||
test("returns 415 for application/x-www-form-urlencoded on POST", async () => {
|
||||
const rejection = requireJsonContentType(
|
||||
makeRequest("POST", "application/x-www-form-urlencoded")
|
||||
);
|
||||
assert.ok(rejection);
|
||||
assert.equal(rejection.status, 415);
|
||||
});
|
||||
|
||||
test("admits application/json", () => {
|
||||
assert.equal(requireJsonContentType(makeRequest("POST", "application/json")), null);
|
||||
});
|
||||
|
||||
test("admits application/json with charset suffix", () => {
|
||||
assert.equal(
|
||||
requireJsonContentType(makeRequest("POST", "application/json; charset=utf-8")),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("admits Application/JSON (case-insensitive)", () => {
|
||||
assert.equal(requireJsonContentType(makeRequest("POST", "Application/JSON")), null);
|
||||
});
|
||||
|
||||
test("admits application/json with leading whitespace", () => {
|
||||
assert.equal(requireJsonContentType(makeRequest("POST", " application/json")), null);
|
||||
});
|
||||
|
||||
test("does not touch GET requests (no body → nothing to reject)", () => {
|
||||
assert.equal(requireJsonContentType(makeRequest("GET", "text/plain")), null);
|
||||
});
|
||||
|
||||
test("does not touch OPTIONS preflight", () => {
|
||||
assert.equal(requireJsonContentType(makeRequest("OPTIONS", null)), null);
|
||||
});
|
||||
|
||||
test("guards PUT and PATCH the same as POST", () => {
|
||||
const putRejection = requireJsonContentType(makeRequest("PUT", "text/plain"));
|
||||
assert.ok(putRejection);
|
||||
assert.equal(putRejection.status, 415);
|
||||
|
||||
const patchRejection = requireJsonContentType(makeRequest("PATCH", "text/plain"));
|
||||
assert.ok(patchRejection);
|
||||
assert.equal(patchRejection.status, 415);
|
||||
});
|
||||
|
||||
test("rejection carries CORS + JSON headers", () => {
|
||||
const rejection = requireJsonContentType(makeRequest("POST", "text/plain"));
|
||||
assert.ok(rejection);
|
||||
assert.equal(rejection.headers.get("Content-Type"), "application/json");
|
||||
assert.ok(rejection.headers.get("Access-Control-Allow-Methods"));
|
||||
});
|
||||
Reference in New Issue
Block a user