fix(dashboard): show custom vision models in LLM selector (#4653)

Integrated into release/v3.8.36 — custom vision models in LLM selector (port 5e5e78d3); release-green
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-23 22:04:29 -03:00
committed by GitHub
parent 57de29a415
commit c02aa7b064
3 changed files with 116 additions and 4 deletions

View File

@@ -190,7 +190,7 @@
"src/app/api/providers/[id]/models/route.ts": 2590,
"src/app/api/providers/[id]/test/route.ts": 917,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1577,
"src/app/api/v1/models/catalog.ts": 1615,
"src/lib/cloudflaredTunnel.ts": 934,
"src/lib/db/apiKeys.ts": 1662,
"src/lib/db/core.ts": 1825,

View File

@@ -65,6 +65,12 @@ interface CustomModelEntry {
supportedEndpoints?: string[];
inputTokenLimit?: number;
isHidden?: boolean;
// User-set "vision-capable" flag (persisted by addCustomModel / replaceCustomModels
// in src/lib/db/models.ts). Surfaced into `/v1/models` via
// getCustomVisionCapabilityFields so user-added vision models appear with
// `capabilities.vision: true` even when their id does not match the
// conservative isVisionModelId heuristic.
supportsVision?: boolean;
}
const FALLBACK_ALIAS_TO_PROVIDER = {
@@ -151,6 +157,39 @@ function getVisionCapabilityFields(modelId: string) {
};
}
/**
* Vision-capability fields for a user-added custom chat model. Honours an
* explicit `supportsVision` flag on the saved entry (the dashboard "vision-
* capable" toggle) IN ADDITION TO the conservative id-based heuristic used by
* built-in models. Without this, a user who registered e.g. `my-vision-llm`
* and ticked vision saw no `capabilities.vision` in `/v1/models`, so the LLM
* selector and downstream routing treated the model as text-only.
*
* Port of upstream decolua/9router 5e5e78d3. Conservative: an explicit
* `supportsVision === false` wins so users can downgrade a mis-classified
* model (same anti-FP discipline as #4071 / #4072).
*/
export function getCustomVisionCapabilityFields(
entry: { supportsVision?: boolean } | null | undefined,
...candidateIds: Array<string | null | undefined>
): { capabilities: { vision: true }; input_modalities: string[]; output_modalities: string[] } | null {
if (entry && entry.supportsVision === false) return null;
if (entry && entry.supportsVision === true) {
return {
capabilities: { vision: true },
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
}
for (const id of candidateIds) {
if (typeof id === "string" && id) {
const fields = getVisionCapabilityFields(id);
if (fields) return fields;
}
}
return null;
}
function qualifyOpenRouterModelId(modelId: string): string {
return modelId.startsWith("openrouter/") ? modelId : `openrouter/${modelId}`;
}
@@ -1264,7 +1303,7 @@ export async function getUnifiedModelsResponse(
}
const visionFields =
modelType === "chat"
? getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId)
? getCustomVisionCapabilityFields(model, aliasId, modelId)
: null;
if (includeAlias) {
@@ -1297,8 +1336,7 @@ export async function getUnifiedModelsResponse(
if (models.some((m) => m.id === providerPrefixedId)) continue;
const providerVisionFields =
modelType === "chat"
? getVisionCapabilityFields(providerPrefixedId) ||
getVisionCapabilityFields(modelId)
? getCustomVisionCapabilityFields(model, providerPrefixedId, modelId)
: null;
models.push({
id: providerPrefixedId,

View File

@@ -0,0 +1,74 @@
/**
* Custom vision models in the LLM selector / `/v1/models` catalog
* (port of upstream decolua/9router 5e5e78d3 — "fix: show custom vision
* models in LLM selector and model list").
*
* Upstream stored an `imageToText` kind on user-added custom models and the
* selector hid them from the default LLM list. OmniRoute already keeps custom
* chat models in the LLM list, but the equivalent gap is the `supportsVision`
* flag on a custom model: the catalog used to only set `capabilities.vision`
* when the model id matched the conservative `isVisionModelId` heuristic
* (`pixtral`, `llava`, `qwen-vl`, …). A user who registered a custom chat
* model called e.g. `my-vision-llm` and explicitly checked "vision-capable"
* still saw no `capabilities.vision` in `/v1/models`, so the LLM selector and
* downstream routing treated it as text-only.
*
* This file pins the OmniRoute-flavoured behaviour:
* • a custom chat model with `supportsVision: true` MUST surface
* `capabilities: { vision: true }` via the catalog helper, regardless of
* whether the model id matches the static vision heuristic.
* • the existing id-based heuristic (`isVisionModelId`) still works.
* • when neither signal is present, no vision fields are emitted (no false
* positives — same conservative guard #4071 / #4072).
*/
import test from "node:test";
import assert from "node:assert/strict";
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
type VisionFields = { capabilities: { vision: true } } | null;
type Helper = (entry: unknown, ...candidateIds: Array<string | null | undefined>) => VisionFields;
const getCustomVisionCapabilityFields = (
catalog as unknown as { getCustomVisionCapabilityFields?: Helper }
).getCustomVisionCapabilityFields;
test("custom vision helper is exported from the catalog module", () => {
assert.equal(
typeof getCustomVisionCapabilityFields,
"function",
"expected getCustomVisionCapabilityFields to be exported from src/app/api/v1/models/catalog.ts"
);
});
test("supportsVision flag on a custom model surfaces capabilities.vision (LLM selector unblock)", () => {
const helper = getCustomVisionCapabilityFields as Helper;
const entry = { id: "my-vision-llm", supportsVision: true };
const fields = helper(entry, "openai-compat/my-vision-llm", "my-vision-llm");
assert.ok(fields, "supportsVision=true must yield vision fields");
assert.equal(fields?.capabilities.vision, true);
});
test("id-based isVisionModelId heuristic still works when supportsVision is unset", () => {
const helper = getCustomVisionCapabilityFields as Helper;
const entry = { id: "pixtral-12b" };
const fields = helper(entry, "openai-compat/pixtral-12b", "pixtral-12b");
assert.ok(fields, "well-known vision model id must yield vision fields");
assert.equal(fields?.capabilities.vision, true);
});
test("no signal → no vision fields (avoids #4071-style false positives)", () => {
const helper = getCustomVisionCapabilityFields as Helper;
const entry = { id: "plain-text-llm" };
const fields = helper(entry, "openai-compat/plain-text-llm", "plain-text-llm");
assert.equal(fields, null);
});
test("explicit supportsVision=false is respected (text-only override)", () => {
const helper = getCustomVisionCapabilityFields as Helper;
// Even if the id matches the heuristic, an explicit false flag wins so the
// user can downgrade a mis-classified model.
const entry = { id: "pixtral-12b", supportsVision: false };
const fields = helper(entry, "alias/pixtral-12b", "pixtral-12b");
assert.equal(fields, null);
});