fix(models): fail open on custom vision DB reads

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-15 05:00:15 -03:00
committed by Xiangzhe
parent 145419cde8
commit f69d23a9f9
4 changed files with 176 additions and 35 deletions

View File

@@ -11,8 +11,12 @@ import {
} from "@/lib/modelCapabilities";
import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";
interface GetModelsDependencies {
createCapabilitySnapshot?: typeof createModelCapabilityResolutionSnapshot;
}
// GET /api/models - Get models with aliases (only from active providers by default)
export async function GET(request: Request) {
export async function handleGetModels(request: Request, dependencies: GetModelsDependencies = {}) {
try {
const { searchParams } = new URL(request.url);
const showAll = searchParams.get("all") === "true";
@@ -100,7 +104,9 @@ export async function GET(request: Request) {
(providerHasFreeModels(model.provider) && isFreeModel(model.provider, { id: model.model }))
);
});
const capabilitySnapshot = createModelCapabilityResolutionSnapshot();
const capabilitySnapshot = (
dependencies.createCapabilitySnapshot ?? createModelCapabilityResolutionSnapshot
)();
const models = candidates.map((m: any) => {
const fullModel = `${m.provider}/${m.model}`;
const available = !activeProviders || activeProviders.has(m.provider);
@@ -122,6 +128,10 @@ export async function GET(request: Request) {
}
}
export async function GET(request: Request) {
return handleGetModels(request);
}
// PUT /api/models - Update model alias
export async function PUT(request) {
let rawBody;

View File

@@ -6,6 +6,7 @@
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";
@@ -92,6 +93,12 @@ export async function getAllCustomModels() {
/** Nested provider → model map of explicit custom-model vision overrides. */
export type CustomModelVisionOverrideMap = ReadonlyMap<string, ReadonlyMap<string, boolean>>;
export type CustomModelVisionDatabase = Pick<SqliteAdapter, "prepare">;
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;
@@ -118,44 +125,57 @@ function readVisionOverrideFromModels(value: string | null, modelId: string): bo
export function getCustomModelVisionOverride(
providerId: string,
modelId: string,
bulk?: CustomModelVisionOverrideMap | null
bulk?: CustomModelVisionOverrideMap | null,
options: CustomModelVisionOverrideReadOptions = {}
): boolean | null {
if (bulk) return bulk.get(providerId)?.get(modelId) ?? null;
const row = getDbInstance()
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
.get(providerId);
return readVisionOverrideFromModels(getKeyValue(row).value, modelId);
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(): CustomModelVisionOverrideMap {
const rows = getDbInstance()
.prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'")
.all();
const result = new Map<string, Map<string, boolean>>();
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<string, boolean>();
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);
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<string, Map<string, boolean>>();
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<string, boolean>();
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.
}
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<string, Map<string, boolean>>();
}
return result;
}
export async function addCustomModel(

View File

@@ -10,7 +10,11 @@
*/
import { listModelCapabilityOverrides } from "@/lib/db/modelCapabilityOverrides";
import { listModelContextOverrides } from "@/lib/db/modelContextOverrides";
import { listCustomModelVisionOverrides, type CustomModelVisionOverrideMap } from "@/lib/db/models";
import {
listCustomModelVisionOverrides,
type CustomModelVisionOverrideMap,
type CustomModelVisionOverrideReadOptions,
} from "@/lib/db/models";
import {
loadAllSyncedCapabilitiesUncached,
type CapabilitiesByProvider,
@@ -27,6 +31,10 @@ export interface ModelCapabilityResolutionSnapshot {
readonly customVisionOverrides: CustomModelVisionOverrideMap;
}
export interface ModelCapabilityResolutionSnapshotOptions {
customModelVision?: CustomModelVisionOverrideReadOptions;
}
function setNestedOverride(
map: Map<string, Map<string, number>>,
provider: string,
@@ -46,7 +54,9 @@ function setNestedOverride(
* Callers must not yield between the bulk reads if they need a coherent view;
* existing catalog generation guards remain authoritative across later yields.
*/
export function createModelCapabilityResolutionSnapshot(): ModelCapabilityResolutionSnapshot {
export function createModelCapabilityResolutionSnapshot(
options: ModelCapabilityResolutionSnapshotOptions = {}
): ModelCapabilityResolutionSnapshot {
const synced = loadAllSyncedCapabilitiesUncached();
const maxTokenOverrides = new Map<string, Map<string, number>>();
@@ -69,6 +79,6 @@ export function createModelCapabilityResolutionSnapshot(): ModelCapabilityResolu
maxTokenOverrides,
maxInputTokenOverrides,
contextOverrides,
customVisionOverrides: listCustomModelVisionOverrides(),
customVisionOverrides: listCustomModelVisionOverrides(options.customModelVision),
};
}

View File

@@ -17,6 +17,8 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const customModelsDb = await import("../../src/lib/db/models.ts");
const modelCapabilities = await import("../../src/lib/modelCapabilities.ts");
const modelsRoute = await import("../../src/app/api/models/route.ts");
async function fetchModels(): Promise<
@@ -83,6 +85,105 @@ test("/api/models retains genuine resolved vision capability for the Video Bridg
);
});
test("custom-model vision DB failures fail open through point, bulk, snapshot, and route reads", async () => {
const failure = new Error("private sqlite failure");
const pointFactories: Array<() => unknown> = [
() => {
throw failure;
},
() => ({
prepare() {
throw failure;
},
}),
() => ({
prepare() {
return {
get() {
throw failure;
},
};
},
}),
];
for (const getDatabase of pointFactories) {
assert.equal(
customModelsDb.getCustomModelVisionOverride("openai", "gpt-4o", undefined, {
getDatabase,
}),
null
);
}
const bulkFactories: Array<() => unknown> = [
...pointFactories.slice(0, 2),
() => ({
prepare() {
return {
all() {
throw failure;
},
};
},
}),
() => ({
prepare() {
return {
all() {
return [
{
get key() {
throw failure;
},
value: "[]",
},
];
},
};
},
}),
];
for (const getDatabase of bulkFactories) {
const overrides = customModelsDb.listCustomModelVisionOverrides({ getDatabase });
assert.equal(overrides.size, 0);
}
const customDbFailure = {
getDatabase: () => {
throw failure;
},
};
const snapshot = modelCapabilities.createModelCapabilityResolutionSnapshot({
customModelVision: customDbFailure,
});
assert.equal(snapshot.customVisionOverrides.size, 0);
assert.equal(
modelCapabilities.getResolvedModelCapabilities("openai/gpt-4o-mini", undefined, snapshot)
.supportsVision,
true,
"ordinary static capability fallback must survive the optional DB read"
);
const response = await modelsRoute.handleGetModels(
new Request("http://localhost/api/models?all=true"),
{
createCapabilitySnapshot: () =>
modelCapabilities.createModelCapabilityResolutionSnapshot({
customModelVision: customDbFailure,
}),
}
);
assert.equal(response.status, 200);
const body = (await response.json()) as {
models: Array<{ provider: string; model: string; supportsVision?: boolean }>;
};
assert.equal(
body.models.find((model) => model.provider === "openai" && model.model === "gpt-4o-mini")
?.supportsVision,
true
);
});
test("#6328 /api/models removes paid models when hidePaidModels is on", async () => {
await providersDb.createProviderConnection({
provider: "openai",