diff --git a/changelog.d/fixes/8032-path-shaped-vision.md b/changelog.d/fixes/8032-path-shaped-vision.md new file mode 100644 index 0000000000..ce7cefa7d5 --- /dev/null +++ b/changelog.d/fixes/8032-path-shaped-vision.md @@ -0,0 +1 @@ +- **fix(providers):** path-shaped multimodal model ids (e.g. `cp/cline-pass/kimi-k3`) resolve native vision via leaf/registry metadata instead of triggering Vision Bridge ([#8032](https://github.com/diegosouzapw/OmniRoute/issues/8032)) — thanks @Prudhvivuda diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index f4bb9868f6..60f3d349dd 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -224,6 +224,13 @@ function heuristicMaxTokens(modelStr: string): boolean { return !blocked; } +/** Last path segment of a path-shaped model id (`cline-pass/kimi-k3` → `kimi-k3`). */ +function leafModelId(modelId: string | null | undefined): string | null { + if (!modelId || !modelId.includes("/")) return null; + const leaf = modelId.split("/").filter(Boolean).pop() ?? null; + return leaf && leaf !== modelId ? leaf : null; +} + function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSpec | undefined { if (modelId) { const byCanonical = getModelSpec(modelId); @@ -235,6 +242,29 @@ function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSp return undefined; } +/** + * #8032: vision-only leaf fallback for path-shaped routed ids. + * + * Must NOT live in getStaticSpec() — that helper also feeds supportsTools / + * supportsThinking / contextWindow / maxOutputTokens. A shared leaf lookup + * incorrectly promotes e.g. aihorde/deepseek/deepseek-v4-flash to the real + * DeepSeek V4 Flash tool-calling spec (#8212 regression). + */ +function getVisionStaticSpec( + modelId: string | null, + rawModel: string | null +): ModelSpec | undefined { + const direct = getStaticSpec(modelId, rawModel); + if (direct) return direct; + for (const candidate of [modelId, rawModel]) { + const leaf = leafModelId(candidate); + if (!leaf) continue; + const byLeaf = getModelSpec(leaf); + if (byLeaf) return byLeaf; + } + return undefined; +} + function getAuthoritativeStaticContextWindow( provider: string | null, modelId: string | null, @@ -318,6 +348,8 @@ function getSyncedCapabilityForResolved( const values = [candidate]; const stripped = stripLatestAlias(candidate); if (stripped) values.push(stripped); + const leaf = leafModelId(candidate); + if (leaf) values.push(leaf); // models.dev often stores OpenAI-family specialty models as qualified // ids under another mapped provider, e.g. vercel + "openai/whisper-1". if (!candidate.includes("/")) { @@ -411,6 +443,14 @@ function resolveVisionCapability( if (synced.attachment === false && modalitiesDeclareVision(allModalities)) { return true; } + // #8032: attachment=false without modalities must not beat authoritative + // registry/spec vision for path-shaped custom/routed ids (e.g. Cline Pass + // `cp/cline-pass/kimi-k3` → MODEL_SPECS["kimi-k3"].supportsVision). + if (synced.attachment === false) { + if (registryModel?.supportsVision === true) return true; + if (spec?.supportsVision === true) return true; + return false; + } return synced.attachment; } @@ -534,8 +574,12 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo const maxTokenOverride = getMaxTokenCapabilityOverride(resolved); + // Vision consults leaf static metadata for path-shaped ids; other capability + // fields keep using the non-leaf `spec` from getStaticSpec() above. + const visionSpec = getVisionStaticSpec(resolved.model, resolved.rawModel); + const supportsVision = resolveVisionCapability( - spec, + visionSpec, registryModel, synced, modalitiesInput, diff --git a/tests/unit/model-capabilities-path-shaped-vision-8032.test.ts b/tests/unit/model-capabilities-path-shaped-vision-8032.test.ts new file mode 100644 index 0000000000..ae53ebef86 --- /dev/null +++ b/tests/unit/model-capabilities-path-shaped-vision-8032.test.ts @@ -0,0 +1,169 @@ +/** + * #8032 — path-shaped custom/routed multimodal ids (e.g. `cp/cline-pass/kimi-k3`) + * must resolve supportsVision=true from leaf static/registry metadata even when + * models.dev sync stores attachment=false with empty modalities for that key. + * + * Without this, Vision Bridge activates and reroutes to openai/gpt-4o-mini. + */ +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-path-shaped-vision-8032-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const modelCapabilities = await import("../../src/lib/modelCapabilities.ts"); + +function buildCapability(overrides: Record = {}) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: "[]", + modalities_output: "[]", + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: null, + limit_output: null, + interleaved_field: null, + ...overrides, + }; +} + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8032 cp/cline-pass/kimi-k3: attachment=false empty modalities → vision via leaf/registry", () => { + modelsDevSync.saveModelsDevCapabilities({ + clinepass: { + "cline-pass/kimi-k3": buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + tool_call: true, + limit_context: 1048576, + }), + }, + }); + + const caps = modelCapabilities.getResolvedModelCapabilities("cp/cline-pass/kimi-k3"); + assert.equal(caps.supportsVision, true); +}); + +test("#8032 leaf fallback: cline-pass/kimi-k3 resolves MODEL_SPECS kimi-k3 vision", () => { + // No synced row — leaf static spec + registry must still confirm vision. + const caps = modelCapabilities.getResolvedModelCapabilities("cline-pass/kimi-k3"); + assert.equal(caps.supportsVision, true); +}); + +test("#8032 leaf fallback is vision-only: aihorde/deepseek/deepseek-v4-flash keeps tools=false", () => { + // Regression guard from PR review (#8495 / #8212): shared getStaticSpec leaf + // lookup previously promoted this live-discovered AI Horde id to the real + // DeepSeek V4 Flash supportsTools:true spec. Leaf lookup must stay vision-only. + const caps = modelCapabilities.getResolvedModelCapabilities( + "aihorde/deepseek/deepseek-v4-flash" + ); + assert.equal(caps.toolCalling, false); + assert.equal(caps.supportsTools, false); + assert.equal( + caps.contextWindow, + null, + "leaf MODEL_SPECS context must not leak onto unrelated path-shaped ids" + ); +}); + +test("#8032 #4071 text-only override still wins over path-shaped sync noise", () => { + modelsDevSync.saveModelsDevCapabilities({ + xiaomi: { + "mimo-v2.5-pro": buildCapability({ + attachment: true, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + }), + }, + }); + + const caps = modelCapabilities.getResolvedModelCapabilities("xiaomi/mimo-v2.5-pro"); + assert.equal(caps.supportsVision, false); +}); + +test("#8032 Vision Bridge skips describe/reroute for cp/cline-pass/kimi-k3 with image", async () => { + modelsDevSync.saveModelsDevCapabilities({ + clinepass: { + "cline-pass/kimi-k3": buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + tool_call: true, + }), + }, + }); + + const model = "cp/cline-pass/kimi-k3"; + assert.equal(modelCapabilities.getResolvedModelCapabilities(model).supportsVision, true); + + const { VisionBridgeGuardrail } = await import("../../src/lib/guardrails/visionBridge.ts"); + let visionCallCount = 0; + const guardrail = new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ + visionBridgeEnabled: true, + visionBridgeModel: "openai/gpt-4o-mini", + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, + }), + callVisionModel: async () => { + visionCallCount++; + return "should not run"; + }, + hasUsableCredentials: async () => null, + }, + }); + + const result = await guardrail.preCall( + { + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image_url", + image_url: { url: "https://example.com/photo.png" }, + }, + ], + }, + ], + }, + { model, log: console } + ); + + assert.equal(result.block, false); + assert.equal(visionCallCount, 0); + assert.equal(result.modifiedPayload, undefined); +});