Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/14081-custom-node-modalities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(providers): custom OpenAI-compatible node models keep their synced vision capability through the Vision Bridge guardrail (#14081)
7 changes: 7 additions & 0 deletions src/lib/db/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export {
type CustomModelVisionDatabase,
type CustomModelVisionOverrideReadOptions,
} from "./models/customVisionOverride";
export {
getSyncedAvailableModelVision,
listSyncedAvailableModelVision,
type SyncedAvailableModelVisionMap,
type SyncedAvailableModelVisionDatabase,
type SyncedAvailableModelVisionReadOptions,
} from "./models/syncedAvailableModelVision";

// ──────────────── Custom Models ────────────────

Expand Down
114 changes: 114 additions & 0 deletions src/lib/db/models/syncedAvailableModelVision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Synced-available-model vision lookup (#14081).
*
* Custom OpenAI-compatible nodes persist a per-connection `supportsVision`
* boolean on their `syncedAvailableModels` row (see `detectVisionInput` in
* `src/lib/providerModels/modelDiscovery.ts`). `/v1/models` already reads
* that field through `buildSyncedCapabilities()` and reports
* `capabilities.vision: true`, but the runtime capability resolver
* (`resolveVisionCapability()` in `src/lib/modelCapabilities.ts`) had no
* code path back to this table at all, so the Vision Bridge guardrail
* disagreed with the catalog it feeds.
*
* Positive-only by design: a row's `supportsVision` is only ever persisted
* as `true` (see `normalizeSyncedAvailableModels`), so a missing entry here
* means "no signal from this source", never "not vision capable" — callers
* must not use `false` from this helper to downgrade another source's verdict.
*/
import type { SqliteAdapter } from "../adapters/types";
import { getDbInstance } from "../core";
import { getKeyValue } from "./shared";
import { normalizeSyncedAvailableModels } from "./synced";

/** Provider → set of model ids with at least one connection's synced row saying supportsVision:true. */
export type SyncedAvailableModelVisionMap = ReadonlyMap<string, ReadonlySet<string>>;
export type SyncedAvailableModelVisionDatabase = Pick<SqliteAdapter, "prepare">;

export interface SyncedAvailableModelVisionReadOptions {
/** Narrow test seam; production uses the canonical DB singleton. */
getDatabase?: () => SyncedAvailableModelVisionDatabase;
}

function collectVisionModelIds(providerId: string, rawValue: string | null): string[] {
if (!rawValue) return [];
let parsed: unknown;
try {
parsed = JSON.parse(rawValue);
} catch {
return [];
}
return normalizeSyncedAvailableModels(parsed, providerId)
.filter((model) => model.supportsVision === true)
.map((model) => model.id);
}

/**
* Bulk-load every provider's synced-available-model vision verdicts with one
* SQLite query, unioned across all of a provider's connections. Mirrors
* `listCustomModelVisionOverrides()` so catalog/snapshot generation stays a
* single bulk read instead of N+1 queries.
*/
export function listSyncedAvailableModelVision(
options: SyncedAvailableModelVisionReadOptions = {}
): SyncedAvailableModelVisionMap {
try {
const db = options.getDatabase?.() ?? getDbInstance();
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'syncedAvailableModels'")
.all();
const result = new Map<string, Set<string>>();
for (const row of rows) {
const { key, value } = getKeyValue(row);
if (!key || !value) continue;
const providerId = key.split(":")[0];
if (!providerId) continue;
const visionIds = collectVisionModelIds(providerId, value);
if (visionIds.length === 0) continue;
let byModel = result.get(providerId);
if (!byModel) {
byModel = new Set();
result.set(providerId, byModel);
}
for (const id of visionIds) byModel.add(id);
}
return result;
} catch {
return new Map<string, Set<string>>();
}
}

/**
* Resolve one provider+model synced-available-model vision verdict. A
* supplied bulk map avoids a SQLite read for request/build-local capability
* resolution (mirrors `getCustomModelVisionOverride()`).
*
* Positive-only: returns `true` when at least one connection's synced row
* for this provider+model has `supportsVision === true`, otherwise `null`
* — never `false`, so this source can never downgrade another one.
*/
export function getSyncedAvailableModelVision(
providerId: string,
modelId: string,
bulk?: SyncedAvailableModelVisionMap | null,
options: SyncedAvailableModelVisionReadOptions = {}
): boolean | null {
if (!providerId || !modelId) return null;
try {
if (bulk) {
return bulk.get(providerId)?.has(modelId) === true ? true : null;
}
const db = options.getDatabase?.() ?? getDbInstance();
const rows = db
.prepare(
"SELECT value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key LIKE ?"
)
.all(`${providerId}:%`);
for (const row of rows) {
const { value } = getKeyValue(row);
if (collectVisionModelIds(providerId, value).includes(modelId)) return true;
}
return null;
} catch {
return null;
}
}
27 changes: 24 additions & 3 deletions src/lib/modelCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
getModelCapabilityOverride,
getReasoningEffortsOverride,
} from "@/lib/db/modelCapabilityOverrides";
import { getCustomModelVisionOverride } from "@/lib/db/models";
import { getCustomModelVisionOverride, getSyncedAvailableModelVision } from "@/lib/db/models";
import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot";
import { resolveAudioCapability, resolveVideoCapability } from "@/lib/modelCapabilityModalities";

Expand Down Expand Up @@ -508,7 +508,8 @@ function resolveVisionCapability(
modalitiesInput: string[],
modalitiesOutput: string[],
modelId?: string,
customVisionOverride?: boolean | null
customVisionOverride?: boolean | null,
syncedAvailableModelVision?: boolean | null
): boolean | null {
const allModalities = [...modalitiesInput, ...modalitiesOutput].map((entry) =>
String(entry).toLowerCase()
Expand All @@ -527,6 +528,14 @@ function resolveVisionCapability(
// image request can never be routed to a blind model (#4071).
if (isKnownTextOnlyDespiteSync(modelId)) return false;

// #14081: a custom OpenAI-compatible node's synced `syncedAvailableModels`
// row already made /v1/models report capabilities.vision:true for this
// model (buildSyncedCapabilities). Agree with that catalog verdict here too
// so the Vision Bridge guardrail does not reroute an image-capable model as
// text-only. Positive-only: this source is never `false`, so it can only
// add vision, never downgrade another source's verdict.
if (syncedAvailableModelVision === true) return true;

if (typeof synced?.attachment === "boolean") {
// #8250: models.dev sometimes ships attachment=false alongside image/video
// modalities (observed for Kimi K3). Prefer the richer modality signal over
Expand Down Expand Up @@ -858,14 +867,26 @@ export function getResolvedModelCapabilities(
)
: null;

// #14081: positive-only vision verdict from a custom node's synced
// `syncedAvailableModels` row, mirroring the catalog's buildSyncedCapabilities.
const syncedAvailableModelVision =
resolved.provider && resolved.model
? getSyncedAvailableModelVision(
resolved.provider,
resolved.model,
snapshot?.syncedAvailableModelVision
)
: null;

const supportsVision = resolveVisionCapability(
visionSpec,
registryModel,
synced,
modalitiesInput,
modalitiesOutput,
lookupKey,
customVisionOverride
customVisionOverride,
syncedAvailableModelVision
);
const supportsAudio = resolveAudioCapability(spec, registryModel, modalitiesInput);
const supportsVideo = resolveVideoCapability(spec, registryModel, modalitiesInput);
Expand Down
5 changes: 5 additions & 0 deletions src/lib/modelCapabilityResolutionSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
listCustomModelVisionOverrides,
type CustomModelVisionOverrideMap,
type CustomModelVisionOverrideReadOptions,
listSyncedAvailableModelVision,
type SyncedAvailableModelVisionMap,
} from "@/lib/db/models";
import {
loadAllSyncedCapabilitiesUncached,
Expand All @@ -35,6 +37,8 @@ export interface ModelCapabilityResolutionSnapshot {
readonly reasoningEffortsOverrides: NestedReasoningEffortsOverrideMap;
readonly contextOverrides: NestedOverrideMap;
readonly customVisionOverrides: CustomModelVisionOverrideMap;
/** #14081: positive-only vision verdicts from synced custom-node model rows. */
readonly syncedAvailableModelVision: SyncedAvailableModelVisionMap;
}

export interface ModelCapabilityResolutionSnapshotOptions {
Expand Down Expand Up @@ -98,5 +102,6 @@ export function createModelCapabilityResolutionSnapshot(
reasoningEffortsOverrides,
contextOverrides,
customVisionOverrides: listCustomModelVisionOverrides(options.customModelVision),
syncedAvailableModelVision: listSyncedAvailableModelVision(),
};
}
163 changes: 163 additions & 0 deletions tests/unit/vision-bridge-custom-node-modalities-14081.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* #14081 — custom OpenAI-compatible node models lose input_modalities, so
* Vision Bridge reroutes image-capable models as text-only.
*
* A custom OpenAI-compatible node's synced model persists a per-connection
* `supportsVision` boolean on its `syncedAvailableModels` row (see
* `detectVisionInput` in `src/lib/providerModels/modelDiscovery.ts`).
* `/v1/models` correctly reads that field through `buildSyncedCapabilities`
* (`src/app/api/v1/models/syncedCapabilities.ts`) and reports
* `capabilities.vision: true`.
*
* Before this fix, the Vision Bridge guardrail (`src/lib/guardrails/
* visionBridge.ts`) gated on `getResolvedModelCapabilities(model).
* supportsVision`, whose `resolveVisionCapability()` had no code path back
* to `syncedAvailableModels` at all — so it disagreed with the catalog it
* feeds and rerouted an image request as if the model were text-only.
*
* This suite proves the fix both through the on-demand DB read path
* (`getResolvedModelCapabilities()` called without a snapshot) and through
* the bulk-loaded `ModelCapabilityResolutionSnapshot` path, and pins the
* positive-only contract: a row with `supportsVision: false` (or no row at
* all) must never be upgraded to `true` by this source (#4071 safety).
*/
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-vision-14081-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "vision-14081-test-secret";

const core = await import("../../src/lib/db/core.ts");
const modelCapabilities = await import("../../src/lib/modelCapabilities.ts");
const snapshotModule = await import("../../src/lib/modelCapabilityResolutionSnapshot.ts");
const syncedCapabilitiesModule = await import("../../src/app/api/v1/models/syncedCapabilities.ts");

const PROVIDER_ID = "custom-node-14081-provider";
const CONNECTION_ID = "custom-node-14081-connection";
// Ids deliberately avoid any substring in VISION_MODEL_ID_FRAGMENTS
// (src/shared/constants/visionModels.ts) -- e.g. "-vision" -- so the last-resort
// model-id heuristic in resolveVisionCapability() can never contaminate these
// assertions; the only source under test is the synced custom-node row.
const VISION_MODEL_ID = "custom-node-14081-alpha-model";
const TEXT_ONLY_MODEL_ID = "custom-node-14081-beta-model";
const ABSENT_MODEL_ID = "custom-node-14081-gamma-model";

test.after(() => {
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
// best-effort cleanup
}
});

function seedFixture() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
const db = core.getDbInstance();

// Exactly what detectVisionInput() persists for a custom OpenAI-compatible
// node whose upstream /v1/models declared
// architecture.input_modalities: ["text","image"] (vision model) vs a
// sibling row that never set the flag (text-only model).
const rows = [
{
id: VISION_MODEL_ID,
name: VISION_MODEL_ID,
source: "imported",
supportsVision: true,
},
{
id: TEXT_ONLY_MODEL_ID,
name: TEXT_ONLY_MODEL_ID,
source: "imported",
supportsVision: false,
},
];

db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)"
).run(`${PROVIDER_ID}:${CONNECTION_ID}`, JSON.stringify(rows));
}

test("issue #14081: /v1/models catalog and getResolvedModelCapabilities agree on vision:true for a synced custom-node row", () => {
seedFixture();

// Sanity: this is the real helper /v1/models uses to build
// `capabilities.vision` for a synced model row -- not in dispute, matches
// the issue's own repro of the catalog output.
const catalogCapabilities = syncedCapabilitiesModule.buildSyncedCapabilities(
{ supportsVision: true },
PROVIDER_ID
);
assert.equal(
catalogCapabilities?.vision,
true,
"sanity: /v1/models catalog reports vision:true for this synced row"
);

// On-demand path (no snapshot): the Vision Bridge guardrail's normal
// per-request call shape.
const resolvedOnDemand = modelCapabilities.getResolvedModelCapabilities({
provider: PROVIDER_ID,
model: VISION_MODEL_ID,
});
assert.strictEqual(
resolvedOnDemand.supportsVision,
true,
"BUG #14081: getResolvedModelCapabilities (on-demand) must agree with the " +
"catalog's vision:true for a synced custom-node row with supportsVision:true"
);

// Snapshot-backed path: catalog/build-local generation must see the same
// verdict as the on-demand path (#9199 parity).
const snapshot = snapshotModule.createModelCapabilityResolutionSnapshot();
const resolvedFromSnapshot = modelCapabilities.getResolvedModelCapabilities(
{ provider: PROVIDER_ID, model: VISION_MODEL_ID },
undefined,
snapshot
);
assert.strictEqual(
resolvedFromSnapshot.supportsVision,
true,
"BUG #14081: getResolvedModelCapabilities (snapshot-backed) must also agree " +
"with the catalog's vision:true for a synced custom-node row"
);
});

test("issue #14081: positive-only contract -- a synced row without supportsVision:true is never upgraded", () => {
seedFixture();

// A synced row with supportsVision:false must not be flipped to true by
// this new source (it may still resolve to false/null through other
// branches of resolveVisionCapability -- this source itself must add
// nothing here).
const resolvedTextOnly = modelCapabilities.getResolvedModelCapabilities({
provider: PROVIDER_ID,
model: TEXT_ONLY_MODEL_ID,
});
assert.notStrictEqual(
resolvedTextOnly.supportsVision,
true,
"positive-only contract violated: a synced row with supportsVision:false " +
"must never be upgraded to true by the syncedAvailableModels vision source"
);

// No synced row at all for this model id -- must not spuriously resolve to
// true from this source either.
const resolvedAbsent = modelCapabilities.getResolvedModelCapabilities({
provider: PROVIDER_ID,
model: ABSENT_MODEL_ID,
});
assert.notStrictEqual(
resolvedAbsent.supportsVision,
true,
"a model id with no syncedAvailableModels row must not resolve to " +
"supportsVision:true from this source"
);
});
Loading