diff --git a/src/app/api/v1/models/[...model]/route.ts b/src/app/api/v1/models/[...model]/route.ts index cb3d22e8b0..43da6fd487 100644 --- a/src/app/api/v1/models/[...model]/route.ts +++ b/src/app/api/v1/models/[...model]/route.ts @@ -9,6 +9,18 @@ export async function OPTIONS() { return handleCorsOptions(); } +/** + * HEAD /v1/models/{model} — availability probe (RFC 9110 §9.3.2). + * Prevents Next.js auto-derived HEAD from streaming the full GET body, + * which caused ~6s hangs for SDK/gateway health probes (#6400). + */ +export async function HEAD() { + return new Response(null, { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + /** * GET /v1/models/{model} — OpenAI-compatible single-model retrieval (#4674). * diff --git a/src/app/api/v1/models/route.ts b/src/app/api/v1/models/route.ts index eb30a4daf7..f884388ae8 100644 --- a/src/app/api/v1/models/route.ts +++ b/src/app/api/v1/models/route.ts @@ -6,12 +6,27 @@ import { getUnifiedModelsResponse } from "./catalog"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "*", }, }); } +/** + * HEAD /v1/models - availability probe (RFC 9110 §9.3.2: HEAD returns headers only) + * + * Explicit handler prevents Next.js auto-derived HEAD from streaming the full + * GET body (200+ providers enumerated on-demand), which caused ~6s hang for + * clients like the OpenAI SDK that use HEAD as a health/preflight probe. + * See: https://github.com/diegosouzapw/OmniRoute/issues/6400 + */ +export async function HEAD() { + return new Response(null, { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + /** * GET /v1/models - OpenAI compatible models list */ diff --git a/tests/unit/head-request-v1-models-6400.test.ts b/tests/unit/head-request-v1-models-6400.test.ts new file mode 100644 index 0000000000..b8481cb1c1 --- /dev/null +++ b/tests/unit/head-request-v1-models-6400.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import * as modelsRoute from "../../src/app/api/v1/models/route"; + +/** + * Regression test for issue #6400. + * + * Before the fix, `src/app/api/v1/models/route.ts` exported only `OPTIONS` and + * `GET`. Next.js 16 App Router auto-derives `HEAD` from `GET` when no explicit + * handler exists, and the derived `HEAD` streams the full `GET` body (which + * the client discards). Because `getUnifiedModelsResponse()` enumerates 200+ + * providers on-demand, the body stream stayed open ~6s — clients issuing HEAD + * as an availability probe (OpenAI SDK, openai-python, httpx, gateway + * health-checkers) stalled until their timeout fired. + * + * RFC 9110 §9.3.2: HEAD MUST close after the headers. + * + * Fix: explicit `HEAD` handler that returns `{ status: 200, body: null }`, and + * `OPTIONS` advertises `HEAD` in `Access-Control-Allow-Methods`. + */ +describe("issue #6400 — HEAD /v1/models returns immediately", () => { + it("exports an explicit HEAD handler", () => { + assert.equal( + typeof (modelsRoute as { HEAD?: unknown }).HEAD, + "function", + "HEAD export missing — Next.js will auto-derive from GET and stream the body" + ); + }); + + it("HEAD returns 200 with a null body (no streaming)", async () => { + const head = (modelsRoute as unknown as { + HEAD: () => Promise; + }).HEAD; + const response = await head(); + assert.equal(response.status, 200); + assert.equal(response.body, null, "HEAD body must be null per RFC 9110 §9.3.2"); + }); + + it("OPTIONS advertises HEAD in Access-Control-Allow-Methods", async () => { + const response = await modelsRoute.OPTIONS(); + const methods = response.headers.get("Access-Control-Allow-Methods") ?? ""; + assert.ok( + /\bHEAD\b/.test(methods), + `expected HEAD in Access-Control-Allow-Methods, got: ${methods}` + ); + }); +});