mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
fix(models): fail open on custom vision DB reads
This commit is contained in:
committed by
Xiangzhe
parent
145419cde8
commit
f69d23a9f9
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user