diff --git a/changelog.d/fixes/13918-lemonade-vision-labels.md b/changelog.d/fixes/13918-lemonade-vision-labels.md new file mode 100644 index 0000000000..0e20f291a9 --- /dev/null +++ b/changelog.d/fixes/13918-lemonade-vision-labels.md @@ -0,0 +1 @@ +- fix(providers): `detectVisionInput` now recognizes Lemonade Server's `labels[]` vision capability, so Lemonade vision models import with `supportsVision` set instead of being treated as text-only (#13918) diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index f2dcf77c15..5536c51544 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -41,12 +41,26 @@ function modalitiesIncludeImage(value: unknown): boolean { ); } +// #13918: Lemonade Server's GET /v1/models exposes capabilities only through a +// `labels[]` string array (e.g. ["chat", "vision", "reasoning", "tool-calling"]) — +// it has none of the modality/architecture fields the other shapes below read. +// See https://lemonade-server.ai/docs/api/openai/. Exact (case-insensitive, +// trimmed) membership test only — not a substring match, per the earlier +// false-positive lesson with bare `gemma` id-fragment matching. +function labelsIncludeVision(value: unknown): boolean { + return ( + Array.isArray(value) && + value.some((entry) => toNonEmptyString(entry)?.toLowerCase() === "vision") + ); +} + /** * #4264: detect image-input (vision) capability from a discovered model record. * Handles the common upstream shapes: an explicit `supportsVision` flag, the * OpenRouter `architecture.input_modalities` array and string `architecture.modality` - * ("text+image->text" — the input side is everything before "->"), and a top-level - * `input_modalities` array. Returns false when the upstream exposes no modality info. + * ("text+image->text" — the input side is everything before "->"), a top-level + * `input_modalities` array, and (#13918) Lemonade Server's `labels[]` array. + * Returns false when the upstream exposes no modality info. */ export function detectVisionInput(record: JsonRecord): boolean { if (record.supportsVision === true) return true; @@ -60,6 +74,9 @@ export function detectVisionInput(record: JsonRecord): boolean { const [inputPart] = modality.toLowerCase().split("->"); if ((inputPart || "").includes("image")) return true; } + + if (labelsIncludeVision(record.labels)) return true; + return false; } diff --git a/tests/unit/lemonade-vision-labels-13918.test.ts b/tests/unit/lemonade-vision-labels-13918.test.ts new file mode 100644 index 0000000000..67354ef83a --- /dev/null +++ b/tests/unit/lemonade-vision-labels-13918.test.ts @@ -0,0 +1,86 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { detectVisionInput } from "@/lib/providerModels/modelDiscovery"; + +// #13918: Lemonade Server's GET /v1/models exposes capabilities only through a +// `labels[]` string array (chat, vision, reasoning, tool-calling, ...) -- it has +// no `architecture.input_modalities` / `input_modalities` / `modality` field, so +// detectVisionInput() never recognizes a vision-labelled Lemonade model. + +test("detectVisionInput recognizes a Lemonade record whose labels[] includes vision", () => { + const lemonadeRecord = { + id: "Gemma-4-26B-A4B", + object: "model", + owned_by: "lemonade", + checkpoint: "amd/gemma-4-26b-a4b", + recipe: "llamacpp", + size: 26000000000, + max_context_window: 262144, + context_length: 262144, + downloaded: true, + labels: ["chat", "vision", "reasoning", "tool-calling"], + }; + + assert.equal( + detectVisionInput(lemonadeRecord), + true, + "expected a Lemonade record labelled `vision` to be detected as vision-capable" + ); +}); + +test("detectVisionInput stays false for a Lemonade record without the vision label", () => { + const textOnly = { + id: "Gemma-4-26B-A4B-text", + owned_by: "lemonade", + labels: ["chat", "tool-calling"], + }; + + assert.equal(detectVisionInput(textOnly), false); +}); + +test("detectVisionInput is case-insensitive and trims whitespace in labels[]", () => { + const mixedCase = { + id: "some-model", + owned_by: "lemonade", + labels: ["chat", " Vision "], + }; + + assert.equal(detectVisionInput(mixedCase), true); +}); + +test("detectVisionInput does not substring-match labels[] entries", () => { + const notVision = { + id: "some-model", + owned_by: "lemonade", + labels: ["chat", "revision-control"], + }; + + assert.equal(detectVisionInput(notVision), false); +}); + +test("detectVisionInput ignores non-array labels without throwing", () => { + assert.doesNotThrow(() => detectVisionInput({ id: "x", labels: "vision" as unknown })); + assert.equal(detectVisionInput({ id: "x", labels: "vision" as unknown }), false); + assert.doesNotThrow(() => detectVisionInput({ id: "x", labels: undefined })); + assert.equal(detectVisionInput({ id: "x", labels: {} as unknown }), false); +}); + +test("detectVisionInput leaves non-Lemonade shapes byte-identical (architecture.modality path)", () => { + const openRouterRecord = { + id: "some/vision-model", + architecture: { + modality: "text+image->text", + }, + }; + + assert.equal(detectVisionInput(openRouterRecord), true); + + const openRouterTextOnly = { + id: "some/text-model", + architecture: { + modality: "text->text", + }, + }; + + assert.equal(detectVisionInput(openRouterTextOnly), false); +});