diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 88d6cf00f4..55282ecf2e 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -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; diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 41af595e7b..ceca42fdc3 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -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>; +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; @@ -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>(); - 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); +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. } - 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>(); } - return result; } export async function addCustomModel( diff --git a/src/lib/modelCapabilityResolutionSnapshot.ts b/src/lib/modelCapabilityResolutionSnapshot.ts index b5724a62bb..ae9f68c000 100644 --- a/src/lib/modelCapabilityResolutionSnapshot.ts +++ b/src/lib/modelCapabilityResolutionSnapshot.ts @@ -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>, 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>(); @@ -69,6 +79,6 @@ export function createModelCapabilityResolutionSnapshot(): ModelCapabilityResolu maxTokenOverrides, maxInputTokenOverrides, contextOverrides, - customVisionOverrides: listCustomModelVisionOverrides(), + customVisionOverrides: listCustomModelVisionOverrides(options.customModelVision), }; } diff --git a/tests/unit/api-models-hide-paid-6328.test.ts b/tests/unit/api-models-hide-paid-6328.test.ts index e9b3becdd1..f2e509c864 100644 --- a/tests/unit/api-models-hide-paid-6328.test.ts +++ b/tests/unit/api-models-hide-paid-6328.test.ts @@ -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",