From 687a5aefc083ccf4a824a8c10f581b01187ee7bd Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:06:41 +0530 Subject: [PATCH] fix(providers): reject image-only models on /v1/chat/completions with clear error (#6457) (#6525) fix(providers): reject image-only models on /v1/chat/completions with a clear error (#6457) Integrated into release/v3.8.47. (thanks @chirag127) --- config/quality/file-size-baseline.json | 5 +- src/sse/handlers/chat.ts | 15 ++++ .../chat-rejects-image-only-model.test.ts | 73 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/unit/chat-rejects-image-only-model.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 7290bbb8c1..f182d0abc5 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -258,7 +258,7 @@ "src/shared/services/cliRuntime.ts": 1100, "src/shared/validation/schemas.ts": 2523, "_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791961 (1080p sizing). Owner-approved. Frozen.", "_rebaseline_2026_07_07_6515_chirag": "PR #6515 (@chirag127) own growth: src/sse/handlers/chat.ts ->1763. Owner-approved rebaseline. Frozen.", "_rebaseline_2026_07_07_6534_chirag": "PR #6534 (@chirag127) own growth: open-sse/services/compression/strategySelector.ts ->1025. Owner-approved rebaseline. Frozen.", - "_rebaseline_2026_07_07_6546_chirag": "PR #6546 (@chirag127) own growth: src/sse/handlers/chatHelpers.ts ->876. Owner-approved rebaseline. Frozen." + "_rebaseline_2026_07_07_6546_chirag": "PR #6546 (@chirag127) own growth: src/sse/handlers/chatHelpers.ts ->876. Owner-approved rebaseline. Frozen.", + "_rebaseline_2026_07_07_6525_chirag_image_guard": "PR #6525 (@chirag127, #6457) own growth: chat.ts ->1778 (reject image-only models on /v1/chat/completions; stacks on #6515). Owner-approved. Frozen." } \ No newline at end of file diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6a713e4727..caf3555c09 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -20,6 +20,7 @@ import { import { getModelInfo, getComboForModel } from "../services/model"; import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts"; import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; import { isSelfInflictedUpstreamTimeout } from "@omniroute/open-sse/handlers/chatCore/cooldownClassification.ts"; import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts"; @@ -399,6 +400,20 @@ export async function handleChat( return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); } + // Reject image-generation models routed to /v1/chat/completions (#6457). + // Image-only models live in IMAGE_PROVIDERS (open-sse/config/imageRegistry.ts) + // and are served by /v1/images/generations. Forwarding them to a chat upstream + // yielded confusing raw provider 400s (e.g. HuggingFace: "not a chat model"). + // getImageModelEntry returns non-null only for models registered in the image + // registry — chat-only models (openai/gpt-4o, etc.) resolve to null and pass. + if (getImageModelEntry(modelStr)) { + log.warn("CHAT", `Rejecting image-generation model on chat endpoint: ${modelStr}`); + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Model '${modelStr}' is an image-generation model and cannot be used on /v1/chat/completions. Use POST /v1/images/generations instead.` + ); + } + // T04: client-provided external session header has priority over generated fingerprint. const externalSessionId = extractExternalSessionId(request.headers); const sessionId = externalSessionId || generateStableSessionId(body); diff --git a/tests/unit/chat-rejects-image-only-model.test.ts b/tests/unit/chat-rejects-image-only-model.test.ts new file mode 100644 index 0000000000..6d4fe33b24 --- /dev/null +++ b/tests/unit/chat-rejects-image-only-model.test.ts @@ -0,0 +1,73 @@ +// #6457: /v1/chat/completions must reject image-only models with a clear 400 pointing +// callers at /v1/images/generations, instead of forwarding to a chat upstream that +// returns a confusing raw provider 400 (HuggingFace: "not a chat model"). +// +// Discriminator: getImageModelEntry(modelStr) — non-null only for models registered +// in open-sse/config/imageRegistry.ts. Chat-only models (openai/gpt-4o etc.) return +// null and pass the guard unchanged. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("chat-rejects-image-only-model"); +const { buildRequest, handleChat, resetStorage } = harness as { + buildRequest: (opts: { body: unknown }) => Request; + handleChat: (req: Request) => Promise; + resetStorage: () => void | Promise; +}; + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + harness.cleanup?.(); +}); + +test("POST /v1/chat/completions with a HuggingFace image model returns 400 + generations hint (#6457)", async () => { + const request = buildRequest({ + body: { + model: "huggingface/stabilityai/stable-diffusion-xl-base-1.0", + messages: [{ role: "user", content: "draw a cat" }], + }, + }); + + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = async (...args: Parameters) => { + fetchCalls++; + return originalFetch(...args); + }; + + try { + const res = await handleChat(request); + assert.equal(res.status, 400, "must reject with 400 before dispatch"); + const body = (await res.json()) as { error?: { message?: string } }; + const msg = body?.error?.message || JSON.stringify(body); + assert.match(msg, /image-generation model/i); + assert.match(msg, /\/v1\/images\/generations/); + assert.equal(fetchCalls, 0, "must not dispatch upstream for an image-only model"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("POST /v1/chat/completions with a chat model still reaches routing (guard is invisible)", async () => { + const request = buildRequest({ + body: { + model: "openai/gpt-4o", + messages: [{ role: "user", content: "hi" }], + }, + }); + + const res = await handleChat(request); + // The guard must not fire on a chat model — the response is whatever downstream + // routing produces (typically a credentials/connection error in the harness). + // The critical assertion is: it is NOT the image-guard 400. + if (res.status === 400) { + const body = (await res.json()) as { error?: { message?: string } }; + const msg = body?.error?.message || JSON.stringify(body); + assert.doesNotMatch(msg, /image-generation model/i, "chat model must not trip the image guard"); + } +});