diff --git a/changelog.d/fixes/12974-custom-vision-advertised-alias.md b/changelog.d/fixes/12974-custom-vision-advertised-alias.md new file mode 100644 index 0000000000..7d86bc2937 --- /dev/null +++ b/changelog.d/fixes/12974-custom-vision-advertised-alias.md @@ -0,0 +1 @@ +- **fix(vision):** Custom Models with "Vision capable" checked no longer have image requests swapped to `glm/glm-4.6v` when the client sends the advertised alias (`vllm/path/...`) or the bare path-shaped id — Vision Bridge now matches the stored override for all three id forms ([#12758](https://github.com/diegosouzapw/OmniRoute/issues/12758)) diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index b716d88173..8b878af1bb 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -6,7 +6,6 @@ import { isRetiredGitHubCopilotModelId } from "@omniroute/open-sse/config/providers/registry/github/retiredModels.ts"; -import type { SqliteAdapter } from "./adapters/types"; import { getDbInstance } from "./core"; import { getProviderConnectionsCount } from "./providers"; import { type JsonRecord, getKeyValue } from "./models/shared"; @@ -53,6 +52,13 @@ export { } from "./models/aliases"; export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias"; export type { SyncedAvailableModel } from "./models/synced"; +export { + getCustomModelVisionOverride, + listCustomModelVisionOverrides, + type CustomModelVisionOverrideMap, + type CustomModelVisionDatabase, + type CustomModelVisionOverrideReadOptions, +} from "./models/customVisionOverride"; // ──────────────── Custom Models ──────────────── @@ -91,93 +97,6 @@ export async function getAllCustomModels() { return result; } -/** Nested provider → model map of explicit custom-model vision overrides. */ -export type CustomModelVisionOverrideMap = ReadonlyMap>; -export type CustomModelVisionDatabase = Pick; - -export interface CustomModelVisionOverrideReadOptions { - /** Narrow test seam; production uses the canonical DB singleton. */ - getDatabase?: () => CustomModelVisionDatabase; -} - -function readVisionOverrideFromModels(value: string | null, modelId: string): boolean | null { - if (!value) return null; - try { - const models = JSON.parse(value) as unknown; - if (!Array.isArray(models)) return null; - const entry = models.find( - (candidate): candidate is { id: string; supportsVision?: boolean } => - candidate !== null && - typeof candidate === "object" && - !Array.isArray(candidate) && - (candidate as { id?: unknown }).id === modelId - ); - return entry && typeof entry.supportsVision === "boolean" ? entry.supportsVision : null; - } catch { - return null; - } -} - -/** - * Resolve one explicit custom-model vision override. A supplied bulk map avoids - * SQLite reads for request/build-local capability resolution. - */ -export function getCustomModelVisionOverride( - providerId: string, - modelId: string, - bulk?: CustomModelVisionOverrideMap | null, - options: CustomModelVisionOverrideReadOptions = {} -): boolean | null { - try { - if (bulk) return bulk.get(providerId)?.get(modelId) ?? null; - const db = options.getDatabase?.() ?? getDbInstance(); - const row = db - .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") - .get(providerId); - return readVisionOverrideFromModels(getKeyValue(row).value, modelId); - } catch { - return null; - } -} - -/** Bulk-load explicit custom-model vision overrides with one SQLite query. */ -export function listCustomModelVisionOverrides( - options: CustomModelVisionOverrideReadOptions = {} -): CustomModelVisionOverrideMap { - try { - const db = options.getDatabase?.() ?? getDbInstance(); - const rows = db - .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") - .all(); - const result = new Map>(); - for (const row of rows) { - const { key, value } = getKeyValue(row); - if (!key || !value) continue; - try { - const models = JSON.parse(value) as unknown; - if (!Array.isArray(models)) continue; - const byModel = new Map(); - for (const candidate of models) { - if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; - const { id, supportsVision } = candidate as { - id?: unknown; - supportsVision?: unknown; - }; - if (typeof id === "string" && typeof supportsVision === "boolean") { - byModel.set(id, supportsVision); - } - } - if (byModel.size > 0) result.set(key, byModel); - } catch { - // Malformed custom-model rows do not participate in capability resolution. - } - } - return result; - } catch { - return new Map>(); - } -} - export async function addCustomModel( providerId: string, modelId: string, diff --git a/src/lib/db/models/customVisionOverride.ts b/src/lib/db/models/customVisionOverride.ts new file mode 100644 index 0000000000..62b6c6bfd7 --- /dev/null +++ b/src/lib/db/models/customVisionOverride.ts @@ -0,0 +1,191 @@ +/** + * Explicit Custom Models "Vision capable" lookup. + * + * Dashboard stores the flag under the connection id (often an + * openai-compatible-chat-* uuid) and the path-shaped model id. Clients send + * the advertised alias (`vllm/path/...`), the bare path, or the internal + * `providerId/modelPath`. parseModel splits on the first slash, so the + * advertised forms miss the stored pair. Match the stored row against all + * three forms before Vision Bridge substitutes a fallback VLM. + */ +import type { SqliteAdapter } from "../adapters/types"; +import { getDbInstance } from "../core"; +import { getKeyValue } from "./shared"; + +/** Nested provider → model map of explicit custom-model vision overrides. */ +export type CustomModelVisionOverrideMap = ReadonlyMap>; +export type CustomModelVisionDatabase = Pick; + +export interface CustomModelVisionOverrideReadOptions { + /** Narrow test seam; production uses the canonical DB singleton. */ + getDatabase?: () => CustomModelVisionDatabase; + /** + * Raw model string from the request (`vllm/orcarouter/Qwen...`, the bare + * path, or `providerId/modelPath`). Used when the parsed provider/model pair + * does not match the stored customModels key. + */ + lookupKey?: string; +} + +type OverrideHit = { providerId: string; modelId: string; supportsVision: boolean }; + +function idsEqual(left: string, right: string): boolean { + return left === right || left.toLowerCase() === right.toLowerCase(); +} + +function isPathShaped(value: string | undefined): boolean { + return Boolean(value && value.includes("/")); +} + +/** Stored custom-model id matches the parsed pair and/or the raw request string. */ +export function customModelIdMatchesRequest( + storedId: string, + requestedModelId: string, + lookupKey?: string +): boolean { + if (!storedId) return false; + if (idsEqual(storedId, requestedModelId)) return true; + if (lookupKey && idsEqual(storedId, lookupKey)) return true; + // Suffix only when the stored id is itself path-shaped. A leaf like "4o" + // must not match lookupKey "openai/gpt-4o". + if ( + isPathShaped(storedId) && + lookupKey && + lookupKey.toLowerCase().endsWith(`/${storedId.toLowerCase()}`) + ) { + return true; + } + return false; +} + +function readVisionOverrideFromModels(value: string | null, modelId: string): boolean | null { + if (!value) return null; + try { + const models = JSON.parse(value) as unknown; + if (!Array.isArray(models)) return null; + const entry = models.find( + (candidate): candidate is { id: string; supportsVision?: boolean } => + candidate !== null && + typeof candidate === "object" && + !Array.isArray(candidate) && + typeof (candidate as { id?: unknown }).id === "string" && + idsEqual((candidate as { id: string }).id, modelId) + ); + return entry && typeof entry.supportsVision === "boolean" ? entry.supportsVision : null; + } catch { + return null; + } +} + +function collectOverrideHits(map: CustomModelVisionOverrideMap): OverrideHit[] { + const hits: OverrideHit[] = []; + for (const [providerId, byModel] of map) { + for (const [modelId, supportsVision] of byModel) { + hits.push({ providerId, modelId, supportsVision }); + } + } + return hits; +} + +function pickMatchingOverride( + hits: OverrideHit[], + providerId: string, + modelId: string, + lookupKey?: string +): boolean | null { + const exact = hits.find( + (hit) => idsEqual(hit.providerId, providerId) && idsEqual(hit.modelId, modelId) + ); + if (exact) return exact.supportsVision; + + const matches = hits.filter((hit) => + customModelIdMatchesRequest(hit.modelId, modelId, lookupKey) + ); + if (matches.length === 0) return null; + const first = matches[0].supportsVision; + if (!matches.every((hit) => hit.supportsVision === first)) return null; + if (providerId) { + const sameProvider = matches.filter((hit) => idsEqual(hit.providerId, providerId)); + if (sameProvider.length === 1) return sameProvider[0].supportsVision; + } + return first; +} + +/** + * Resolve one explicit custom-model vision override. A supplied bulk map avoids + * SQLite reads for request/build-local capability resolution. + */ +export function getCustomModelVisionOverride( + providerId: string, + modelId: string, + bulk?: CustomModelVisionOverrideMap | null, + options: CustomModelVisionOverrideReadOptions = {} +): boolean | null { + try { + const lookupKey = options.lookupKey; + const canScan = isPathShaped(modelId) || isPathShaped(lookupKey); + if (!providerId && !canScan) return null; + + if (bulk) { + if (!canScan && providerId) { + return bulk.get(providerId)?.get(modelId) ?? null; + } + return pickMatchingOverride(collectOverrideHits(bulk), providerId, modelId, lookupKey); + } + const db = options.getDatabase?.() ?? getDbInstance(); + if (providerId) { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") + .get(providerId); + const exact = readVisionOverrideFromModels(getKeyValue(row).value, modelId); + if (exact !== null) return exact; + } + if (!canScan) return null; + return pickMatchingOverride( + collectOverrideHits(listCustomModelVisionOverrides({ getDatabase: () => db })), + providerId, + modelId, + lookupKey + ); + } catch { + return null; + } +} + +/** Bulk-load explicit custom-model vision overrides with one SQLite query. */ +export function listCustomModelVisionOverrides( + options: CustomModelVisionOverrideReadOptions = {} +): CustomModelVisionOverrideMap { + try { + const db = options.getDatabase?.() ?? getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") + .all(); + const result = new Map>(); + for (const row of rows) { + const { key, value } = getKeyValue(row); + if (!key || !value) continue; + try { + const models = JSON.parse(value) as unknown; + if (!Array.isArray(models)) continue; + const byModel = new Map(); + for (const candidate of models) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const { id, supportsVision } = candidate as { + id?: unknown; + supportsVision?: unknown; + }; + if (typeof id === "string" && typeof supportsVision === "boolean") { + byModel.set(id, supportsVision); + } + } + if (byModel.size > 0) result.set(key, byModel); + } catch { + // Malformed custom-model rows do not participate in capability resolution. + } + } + return result; + } catch { + return new Map>(); + } +} diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 9d9572a7b1..1d357471a2 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -845,14 +845,16 @@ export function getResolvedModelCapabilities( // fields keep using the non-leaf `spec` from getStaticSpec() above. const visionSpec = getVisionStaticSpec(resolved.model, resolved.rawModel); - // #9195: read the custom model's supportsVision override from the DB so the - // dashboard "Vision capable" toggle affects Combo routing. + // #9195 / #12758: keep the original provider&&model short-circuit. All + // three advertised id forms still parse to both halves; the matcher + // recovers the stored connection-id row via lookupKey / path leftover. const customVisionOverride = resolved.provider && resolved.model ? getCustomModelVisionOverride( resolved.provider, resolved.model, - snapshot?.customVisionOverrides + snapshot?.customVisionOverrides, + { lookupKey: resolved.lookupKey ?? resolved.rawModel ?? lookupKey } ) : null; diff --git a/tests/unit/vision-bridge-custom-path-id-12758.test.ts b/tests/unit/vision-bridge-custom-path-id-12758.test.ts new file mode 100644 index 0000000000..63bceb033f --- /dev/null +++ b/tests/unit/vision-bridge-custom-path-id-12758.test.ts @@ -0,0 +1,200 @@ +/** + * #12758 — vision-bridge must honor Custom Models "Vision capable" for the + * same three id forms /v1/models advertises, not only the internal + * providerId/modelPath string. + * + * parseModel splits on the first slash, so a path-shaped custom id such as + * `orcarouter/Qwen3.8-27B-Uncensored-NVFP4` is looked up as provider + * `orcarouter` + model `Qwen3.8-...`. The override lives under the real + * openai-compatible connection id. Text chat already resolves that record; + * the bridge used a narrower pair and silently swapped in glm/glm-4.6v. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-12758-vision-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "custom-vision-12758-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { addCustomModel, getCustomModelVisionOverride } = await import("../../src/lib/db/models.ts"); +const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts"); +const { VisionBridgeGuardrail } = await import("../../src/lib/guardrails/visionBridge.ts"); +import type { GuardrailContext } from "../../src/lib/guardrails/base.ts"; +import type { VisionModelConfig } from "../../src/lib/guardrails/visionBridgeHelpers.ts"; + +const CONNECTION_ID = "openai-compatible-chat-12758-vllm"; +const CUSTOM_MODEL_ID = "orcarouter/Qwen3.8-27B-Uncensored-NVFP4"; +const ADVERTISED_ALIAS = `vllm/${CUSTOM_MODEL_ID}`; +const FULL_INTERNAL = `${CONNECTION_ID}/${CUSTOM_MODEL_ID}`; +const FALLBACK_VISION_MODEL = "glm/glm-4.6v"; + +const ID_FORMS = [ + { name: "advertised alias vllm/path", model: ADVERTISED_ALIAS }, + { name: "bare path-shaped id", model: CUSTOM_MODEL_ID }, + { name: "full providerId/modelPath", model: FULL_INTERNAL }, +] as const; + +async function seedVisionCustomModel() { + await addCustomModel( + CONNECTION_ID, + CUSTOM_MODEL_ID, + "Qwen 3.8 vision", + "manual", + "chat-completions", + ["chat"], + undefined, + {}, + true + ); +} + +function imagePayload(model: string): Record { + return { + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What color is this image? One word." }, + { + type: "image_url", + image_url: { url: "https://example.com/swatch.png" }, + }, + ], + }, + ], + }; +} + +test.beforeEach(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + await seedVisionCustomModel(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#12758 custom vision override matches all three advertised id forms", () => { + assert.equal( + getCustomModelVisionOverride(CONNECTION_ID, CUSTOM_MODEL_ID), + true, + "exact connection-id lookup is the control" + ); + + assert.equal( + getCustomModelVisionOverride("vllm", CUSTOM_MODEL_ID, undefined, { + lookupKey: ADVERTISED_ALIAS, + }), + true, + "vllm/path advertised alias must resolve the custom override" + ); + + assert.equal( + getCustomModelVisionOverride("orcarouter", "Qwen3.8-27B-Uncensored-NVFP4", undefined, { + lookupKey: CUSTOM_MODEL_ID, + }), + true, + "first-slash split of the path-shaped id must still hit the override" + ); +}); + +for (const form of ID_FORMS) { + test(`#12758 capabilities.supportsVision is true for ${form.name}`, () => { + const caps = getResolvedModelCapabilities(form.model); + assert.equal( + caps.supportsVision, + true, + `${form.model} must inherit the Custom Models Vision capable flag` + ); + }); + + test(`#12758 vision-bridge does not swap ${form.name} to ${FALLBACK_VISION_MODEL}`, async () => { + let visionCallCount = 0; + const guardrail = new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ + visionBridgeEnabled: true, + visionBridgeModel: FALLBACK_VISION_MODEL, + }), + callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => { + visionCallCount++; + return "should never describe"; + }, + hasUsableCredentials: async () => null, + }, + }); + + const payload = imagePayload(form.model); + const result = await guardrail.preCall(payload, { + model: form.model, + log: { debug() {}, info() {}, warn() {}, error() {} }, + } as unknown as GuardrailContext); + + assert.equal(result.block, false); + assert.equal(visionCallCount, 0, "native vision must not call the describe model"); + assert.equal( + result.modifiedPayload, + undefined, + `${form.model} must not be rewritten to ${FALLBACK_VISION_MODEL}` + ); + const rewritten = (result.modifiedPayload as { model?: string } | undefined)?.model; + assert.notEqual(rewritten, FALLBACK_VISION_MODEL); + }); +} + +test("#12758 explicit supportsVision:false still wins on the advertised alias", async () => { + const textOnlyId = "orcarouter/text-only-qwen"; + await addCustomModel( + CONNECTION_ID, + textOnlyId, + "Qwen text only", + "manual", + "chat-completions", + ["chat"], + undefined, + {}, + false + ); + const advertised = `vllm/${textOnlyId}`; + assert.equal( + getCustomModelVisionOverride("vllm", textOnlyId, undefined, { lookupKey: advertised }), + false + ); + assert.equal(getResolvedModelCapabilities(advertised).supportsVision, false); +}); + +test("#12758 bare registry id does not inherit an unrelated custom vision flag", () => { + assert.equal( + getCustomModelVisionOverride("", "gpt-4o", undefined, { lookupKey: "gpt-4o" }), + null, + "empty-provider gpt-4o must not scan customModels" + ); + assert.equal(getResolvedModelCapabilities("gpt-4o").supportsVision, true); +}); + +test("#12758 a leaf stored id does not suffix-steal openai/gpt-4o", async () => { + await addCustomModel( + CONNECTION_ID, + "4o", + "stolen leaf", + "manual", + "chat-completions", + ["chat"], + undefined, + {}, + true + ); + assert.equal( + getCustomModelVisionOverride("openai", "gpt-4o", undefined, { lookupKey: "openai/gpt-4o" }), + null, + "leaf '4o' must not match lookupKey openai/gpt-4o" + ); +});