fix(providers): detect Lemonade labels[] vision capability (#13918) (#14023)

detectVisionInput() only recognized supportsVision, architecture.input_modalities,
top-level input_modalities, and architecture/modality string shapes. Lemonade
Server's GET /v1/models exposes capabilities only through a labels[] string
array (e.g. ["chat", "vision", "reasoning", "tool-calling"]), so a
vision-labelled Lemonade model imported with supportsVision unset and was
advertised as text-only.

Add a fifth branch that does a case-insensitive, trimmed EXACT membership
test for "vision" in record.labels[] (not a substring match, per the prior
false-positive lesson with bare gemma id-fragment matching). Purely additive
- all four existing shapes stay byte-identical, proven by a new regression
test that exercises the architecture.modality path unchanged.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-18 11:55:54 -03:00
committed by GitHub
parent 9c9ad6bbde
commit 3b535968c4
3 changed files with 106 additions and 2 deletions

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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);
});