Files
OmniRoute/tests/unit/chat-rejects-image-only-model.test.ts
Praveen K Palaniswamy 65e81158ab fix(ollama): route models by advertised capability (#11088)
Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host.

Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean.

Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
2026-08-23 11:45:01 -03:00

123 lines
4.2 KiB
TypeScript

// #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, combosDb, handleChat, resetStorage } = harness as {
buildRequest: (opts: { body: unknown }) => Request;
combosDb: {
createCombo: (data: Record<string, unknown>) => Promise<unknown>;
};
handleChat: (req: Request) => Promise<Response>;
resetStorage: () => void | Promise<void>;
};
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<typeof originalFetch>) => {
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");
}
});
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: {
model: "codex/gpt-5.6-sol",
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 model present in the chat catalog must not trip the image-only guard"
);
}
});