diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 2b5f0ef0a6..8ffba39d62 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -48,7 +48,7 @@ import { checkAndRefreshToken } from "../services/tokenRefresh"; import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry"; import { rejectPeerRequest } from "@/shared/resilience/peerRouting"; import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs"; -import { updateCombo } from "@/lib/db/combos"; +import { getComboByName, updateCombo } from "@/lib/db/combos"; import { isModelAllowedForKey } from "@/lib/db/apiKeys"; import { promoteSuccessfulComboModel } from "@/lib/combos/autoPromote"; import { @@ -462,10 +462,14 @@ async function handleChatImplementation( // image-registry match is only image-only when the same provider/model pair is // absent from the chat catalog. const imageModel = getImageModelEntry(modelStr); + // Exact stored combo names take precedence over colliding bare image aliases. + // Keep this narrower than getComboForModel() so mappings and synthetic aliases + // retain their existing resolution order. + const isExactStoredCombo = imageModel ? Boolean(await getComboByName(modelStr)) : false; const isChatCatalogModel = imageModel ? getModelsByProviderId(imageModel.provider).some((model) => model.id === imageModel.model) : false; - if (imageModel && !isChatCatalogModel) { + if (imageModel && !isExactStoredCombo && !isChatCatalogModel) { log.warn("CHAT", `Rejecting image-generation model on chat endpoint: ${modelStr}`); return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/tests/unit/chat-rejects-image-only-model.test.ts b/tests/unit/chat-rejects-image-only-model.test.ts index a485544290..f84526b20d 100644 --- a/tests/unit/chat-rejects-image-only-model.test.ts +++ b/tests/unit/chat-rejects-image-only-model.test.ts @@ -11,8 +11,11 @@ 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 { +const { buildRequest, combosDb, handleChat, resetStorage } = harness as { buildRequest: (opts: { body: unknown }) => Request; + combosDb: { + createCombo: (data: Record) => Promise; + }; handleChat: (req: Request) => Promise; resetStorage: () => void | Promise; }; @@ -72,6 +75,32 @@ test("POST /v1/chat/completions with a chat model still reaches routing (guard i } }); +test("POST /v1/chat/completions routes a stored chat combo whose name is an image alias (#8986)", async () => { + await combosDb.createCombo({ + name: "fast", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + + const request = buildRequest({ + body: { + model: "fast", + messages: [{ role: "user", content: "hi" }], + }, + }); + + const res = await handleChat(request); + 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, + "a stored chat combo must take precedence over a colliding image alias" + ); + } +}); + test("POST /v1/chat/completions allows a model registered for both chat and image generation", async () => { const request = buildRequest({ body: {