From e6a0fd104d6641cc3d84a154c44434e400bdc229 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:53:20 -0400 Subject: [PATCH] Fix model sync import handling (#1755) * Fix model sync import handling * Align model import storage semantics * Address model review feedback --- .../dashboard/providers/[id]/page.tsx | 17 +- src/app/api/pricing/models/route.ts | 81 ++++--- .../api/providers/[id]/sync-models/route.ts | 162 +++++++++++--- src/app/api/v1beta/models/route.ts | 51 ++++- src/lib/combos/builderOptions.ts | 13 +- src/lib/db/models.ts | 74 ++++++- src/lib/providerModels/managedModelImport.ts | 171 ++++++++++----- src/lib/providerModels/modelDiscovery.ts | 2 +- src/shared/components/ModelSelectModal.tsx | 7 +- src/shared/utils/modelCatalogSearch.ts | 10 +- tests/unit/db-models-crud.test.ts | 10 +- tests/unit/model-catalog-search.test.ts | 8 +- tests/unit/model-catalog-source.test.ts | 10 +- tests/unit/model-sync-route.test.ts | 206 ++++++++++++------ tests/unit/models-catalog-route.test.ts | 10 +- tests/unit/provider-models-route.test.ts | 10 +- tests/unit/v1beta-models-route.test.ts | 55 +++++ 17 files changed, 651 insertions(+), 246 deletions(-) create mode 100644 tests/unit/v1beta-models-route.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 25ad3c7f7b..5cefea5c4d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -460,7 +460,7 @@ interface CooldownTimerProps { function getModelSourceBadgeClass(source?: string): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": + case "imported": return "border-sky-500/30 bg-sky-500/10 text-sky-300"; case "custom": return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"; @@ -1041,12 +1041,12 @@ export default function ProviderDetailPage() { const isOAuth = providerSupportsOAuth && !providerSupportsPat; const registryModels = getModelsByProviderId(providerId); // Prefer synced API-discovered models when available, then merge built-ins - // and user-managed custom/imported models without duplicating IDs. + // and user-managed custom models without duplicating IDs. const models = useMemo(() => { if (providerId === "gemini") { return syncedAvailableModels.map((model: any) => ({ ...model, - source: model?.source === "api-sync" ? "api-sync" : "api-sync", + source: "imported", })); } @@ -1061,7 +1061,7 @@ export default function ProviderDetailPage() { .map((model: any) => ({ id: model.id, name: model.name || model.id, - source: "api-sync", + source: "imported", })); const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); const customExtras = modelMeta.customModels @@ -1069,7 +1069,7 @@ export default function ProviderDetailPage() { .map((cm: any) => ({ id: cm.id, name: cm.name || cm.id, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); return [...builtInModels, ...syncedExtras, ...customExtras]; }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); @@ -2097,6 +2097,9 @@ export default function ProviderDetailPage() { typeof data.importedChanges?.total === "number" ? data.importedChanges.total : importedCount; + const totalChangedCount = + changedCount + + (typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0); if (importedModels.length === 0) { setImportProgress((prev) => ({ @@ -2113,7 +2116,7 @@ export default function ProviderDetailPage() { ], importedCount, })); - if (changedCount > 0) { + if (totalChangedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); @@ -2142,7 +2145,7 @@ export default function ProviderDetailPage() { importedCount, })); - if (changedCount > 0) { + if (totalChangedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); diff --git a/src/app/api/pricing/models/route.ts b/src/app/api/pricing/models/route.ts index 98882c4af1..e25ba2fcc1 100644 --- a/src/app/api/pricing/models/route.ts +++ b/src/app/api/pricing/models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { getAllCustomModels, getPricing } from "@/lib/localDb"; +import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb"; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -20,8 +20,9 @@ function asModelArray(value: unknown): Array<{ id?: string; name?: string }> { * GET /api/pricing/models * Returns the full model catalog merged from three sources: * 1. providerRegistry (hardcoded) - * 2. customModels (DB — user-added or imported via /models) - * 3. pricing data (DB — models with pricing configured but not in sources 1/2) + * 2. syncedAvailableModels (DB — discovered/imported from provider /models) + * 3. customModels (DB — manually added models) + * 4. pricing data (DB — models with pricing configured but not in sources 1/2/3) */ export async function GET() { try { @@ -46,25 +47,14 @@ export async function GET() { }; } - // ── 2. Custom models (DB) ─────────────────────────────────────── - let customModelsMap: Record = {}; - try { - customModelsMap = asRecord(await getAllCustomModels()); - } catch { - /* DB may not be ready */ - } - - for (const [providerId, rawModels] of Object.entries(customModelsMap)) { - const models = asModelArray(rawModels); - // Resolve alias — check if a registry entry maps this providerId - let alias = providerId; + const resolveAlias = (providerId: string) => { for (const entry of Object.values(REGISTRY)) { - if (entry.id === providerId) { - alias = entry.alias || entry.id; - break; - } + if (entry.id === providerId) return entry.alias || entry.id; } + return providerId; + }; + const ensureCatalogProvider = (providerId: string, alias: string) => { if (!catalog[alias]) { catalog[alias] = { id: providerId, @@ -75,25 +65,52 @@ export async function GET() { models: [], }; } + return catalog[alias]; + }; + + const appendDbModels = (providerId: string, rawModels: unknown) => { + const models = asModelArray(rawModels); + const alias = resolveAlias(providerId); + const providerCatalog = ensureCatalogProvider(providerId, alias); + const existingIds = new Set(providerCatalog.models.map((m) => m.id)); - const existingIds = new Set(catalog[alias].models.map((m) => m.id)); for (const model of models) { const modelId = typeof model.id === "string" ? model.id : null; - if (!modelId || existingIds.has(modelId)) { - continue; - } - if (!existingIds.has(modelId)) { - catalog[alias].models.push({ - id: modelId, - name: typeof model.name === "string" && model.name.trim() ? model.name : modelId, - custom: true, - }); - existingIds.add(modelId); - } + if (!modelId || existingIds.has(modelId)) continue; + providerCatalog.models.push({ + id: modelId, + name: typeof model.name === "string" && model.name.trim() ? model.name : modelId, + custom: true, + }); + existingIds.add(modelId); } + }; + + // ── 2. Synced available models (DB) ───────────────────────────── + let syncedModelsMap: Record = {}; + try { + syncedModelsMap = asRecord(await getAllSyncedAvailableModels()); + } catch { + /* DB may not be ready */ } - // ── 3. Pricing-only models (DB) ───────────────────────────────── + for (const [providerId, rawModels] of Object.entries(syncedModelsMap)) { + appendDbModels(providerId, rawModels); + } + + // ── 3. Custom models (DB) ─────────────────────────────────────── + let customModelsMap: Record = {}; + try { + customModelsMap = asRecord(await getAllCustomModels()); + } catch { + /* DB may not be ready */ + } + + for (const [providerId, rawModels] of Object.entries(customModelsMap)) { + appendDbModels(providerId, rawModels); + } + + // ── 4. Pricing-only models (DB) ───────────────────────────────── let pricingData: Record = {}; try { pricingData = await getPricing(); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 9844deeb4d..a8b807985b 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; +import { getSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { importManagedModels, type ManagedModelImportMode, @@ -10,6 +11,7 @@ import { buildModelSyncInternalHeaders, isModelSyncInternalRequest, } from "@/shared/services/modelSyncScheduler"; +import { GET as getProviderModels } from "../models/route"; type JsonRecord = Record; @@ -28,8 +30,8 @@ function normalizeModelForComparison(model: unknown) { const rawSource = toNonEmptyString(record.source)?.toLowerCase(); const source = rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported" - ? "api-sync" - : rawSource || "auto-sync"; + ? "imported" + : rawSource || "manual"; const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions"; const supportedEndpoints = Array.isArray(record.supportedEndpoints) ? Array.from( @@ -56,6 +58,41 @@ function isManagedSyncedModel(model: unknown) { return source === "api-sync" || source === "auto-sync" || source === "imported"; } +function getErrorMessageFromPayload(payload: JsonRecord): string | null { + const error = payload.error; + if (typeof error === "string" && error.trim().length > 0) { + return error.trim(); + } + + const errorRecord = asRecord(error); + return toNonEmptyString(errorRecord.message) || toNonEmptyString(payload.message); +} + +async function readJsonResponse(response: Response): Promise<{ + data: JsonRecord; + parseError: string | null; +}> { + const body = await response.text(); + if (!body.trim()) { + return { + data: {}, + parseError: "Empty response body from /models", + }; + } + + try { + return { + data: asRecord(JSON.parse(body)), + parseError: null, + }; + } catch { + return { + data: {}, + parseError: "Invalid JSON response from /models", + }; + } +} + function summarizeModelChanges(previousModels: unknown, nextModels: unknown) { const previousList = Array.isArray(previousModels) ? previousModels : []; const nextList = Array.isArray(nextModels) ? nextModels : []; @@ -117,13 +154,54 @@ function getModelSyncChannelLabel(connection: unknown) { ); } +async function fetchProviderModelsForSync(request: Request, connectionId: string) { + // Construct a safe localhost URL from the incoming request's origin. + // The route only accepts authenticated or internal-scheduler requests, + // and the path is hardcoded — no user-controlled URL components reach fetch. + const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + const incomingUrl = new URL(request.url); + const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) + ? incomingUrl.origin + : `http://127.0.0.1:${process.env.PORT || "20128"}`; + const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models?refresh=true`; + const headers = { + cookie: request.headers.get("cookie") || "", + ...buildModelSyncInternalHeaders(), + }; + + try { + return await fetch(new URL(modelsPath, safeOrigin).href, { + method: "GET", + cache: "no-store", + headers, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[ModelSync] Internal /models self-fetch failed for ${connectionId.slice( + 0, + 8 + )}; falling back to in-process route: ${message}` + ); + + return getProviderModels( + new Request(new URL(modelsPath, "http://localhost").href, { + method: "GET", + headers, + }), + { params: { id: connectionId } } + ); + } +} + /** * POST /api/providers/[id]/sync-models * - * Fetches the model list from a provider's /models endpoint and replaces the - * full custom models list for that provider while refreshing the per-connection - * discovery cache. Successful syncs only write a call log when the fetched - * channel actually changes the stored model list. + * Fetches the model list from a provider's /models endpoint, stores discovered + * models in the per-connection available-model cache, and removes matching + * upstream-discovered rows from the provider's custom model list. Successful + * syncs only write a call log when the fetched channel or custom model cleanup + * changes stored model state. * * Used by: * - modelSyncScheduler (auto-sync on interval) @@ -153,30 +231,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: logProvider = toNonEmptyString(connection.provider) || "unknown"; channelLabel = getModelSyncChannelLabel(connection); + const previousSyncedAvailableModelsForConnection = await getSyncedAvailableModelsForConnection( + logProvider, + id + ); - // Fetch models from the existing /api/providers/[id]/models endpoint. - // Construct a safe localhost URL from the incoming request's origin. - // The route only accepts authenticated or internal-scheduler requests, - // and the path is hardcoded — no user-controlled URL components reach fetch. - const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); - const incomingUrl = new URL(request.url); - const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) - ? incomingUrl.origin - : `http://127.0.0.1:${process.env.PORT || "20128"}`; - const modelsPath = `/api/providers/${encodeURIComponent(id)}/models?refresh=true`; - const modelsRes = await fetch(new URL(modelsPath, safeOrigin).href, { - method: "GET", - cache: "no-store", - headers: { - cookie: request.headers.get("cookie") || "", - ...buildModelSyncInternalHeaders(), - }, - }); + const modelsRes = await fetchProviderModelsForSync(request, id); const duration = Date.now() - start; - const modelsData = await modelsRes.json(); + const { data: modelsData, parseError } = await readJsonResponse(modelsRes); + const payloadError = getErrorMessageFromPayload(modelsData); - if (!modelsRes.ok) { + if (!modelsRes.ok || parseError) { + const responseStatus = modelsRes.ok ? 502 : modelsRes.status; + const logError = payloadError || parseError || `HTTP ${modelsRes.status}`; + const responseError = payloadError || parseError || "Failed to fetch models"; // Log the failed attempt await saveCallLog({ method: "GET", @@ -187,22 +256,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: sourceFormat: "-", connectionId: id, duration, - error: modelsData.error || `HTTP ${modelsRes.status}`, + error: logError, requestType: "model-sync", + ...(parseError + ? { + responseBody: { + upstreamStatus: modelsRes.status, + parseError, + }, + } + : {}), }); return NextResponse.json( - { error: modelsData.error || "Failed to fetch models" }, - { status: modelsRes.status } + { + error: responseError, + ...(parseError ? { upstreamStatus: modelsRes.status } : {}), + }, + { status: responseStatus } ); } const fetchedModels = modelsData.models || []; const { previousModels, + previousSyncedAvailableModels, persistedModels, importedModels, discoveredModels, + syncedAvailableModels, syncedAliases, importedChanges, } = await importManagedModels({ @@ -210,22 +292,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: connectionId: id, fetchedModels, mode, + previousSyncedAvailableModels: previousSyncedAvailableModelsForConnection, }); - const modelChanges = summarizeModelChanges(previousModels, persistedModels); + const effectiveAvailableModels = + discoveredModels.length > 0 ? discoveredModels : syncedAvailableModels; + const modelChanges = summarizeModelChanges( + previousSyncedAvailableModels, + effectiveAvailableModels + ); + const customModelChanges = summarizeModelChanges(previousModels, persistedModels); const syncedModelsCount = - discoveredModels.length > 0 - ? discoveredModels.length + effectiveAvailableModels.length > 0 + ? effectiveAvailableModels.length : persistedModels.filter((model) => isManagedSyncedModel(model)).length; const availableModelsCount = new Set( - [...persistedModels, ...discoveredModels] + [...persistedModels, ...effectiveAvailableModels] .map((model) => toNonEmptyString(asRecord(model).id)) .filter((modelId): modelId is string => Boolean(modelId)) ).size; const importedCount = importedChanges.added; const updatedCount = importedChanges.updated; + const shouldLog = modelChanges.total > 0 || customModelChanges.total > 0; - if (modelChanges.total > 0) { + if (shouldLog) { await saveCallLog({ method: "GET", path: `/api/providers/${id}/models`, @@ -243,6 +333,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: provider: logProvider, channel: channelLabel, modelChanges, + customModelChanges, importedCount, updatedCount, mode, @@ -258,10 +349,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: availableModelsCount, syncedAliases, modelChanges, + customModelChanges, importedCount, updatedCount, importedChanges, - logged: modelChanges.total > 0, + logged: shouldLog, models: persistedModels, importedModels, }); diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 37390e3f18..5d5aec0a8f 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,5 +1,9 @@ import { PROVIDER_MODELS } from "@/shared/constants/models"; -import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models"; +import { + getAllCustomModels, + getAllSyncedAvailableModels, + getSyncedAvailableModels, +} from "@/lib/db/models"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; import { getSyncedCapabilities } from "@/lib/modelsDevSync"; @@ -67,6 +71,46 @@ export async function GET() { console.error("[v1beta/models] Error fetching synced Gemini models:", err); } + const existingNames = new Set(models.map((model) => (model as any).name)); + + // Synced/imported models for non-Gemini providers + try { + const syncedModelsMap = await getAllSyncedAvailableModels(); + for (const [providerId, syncedModels] of Object.entries(syncedModelsMap)) { + if (providerId === "gemini") continue; + if (!Array.isArray(syncedModels)) continue; + for (const m of syncedModels) { + if (!m || typeof m.id !== "string") continue; + const name = `models/${providerId}/${m.id}`; + if (existingNames.has(name)) continue; + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: m.id, + }); + models.push({ + name, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: + typeof m.inputTokenLimit === "number" + ? m.inputTokenLimit + : resolved.maxInputTokens || resolved.contextWindow || 128000, + outputTokenLimit: + typeof m.outputTokenLimit === "number" + ? m.outputTokenLimit + : resolved.maxOutputTokens || 8192, + ...(m.supportsThinking === true || resolved.supportsThinking === true + ? { thinking: true } + : {}), + }); + existingNames.add(name); + } + } + } catch { + // Synced models are optional — skip on error + } + // Custom models (use stored metadata from provider APIs) try { const customModelsMap = (await getAllCustomModels()) as Record; @@ -83,8 +127,10 @@ export async function GET() { provider: providerId, model: String(m.id), }); + const name = `models/${providerId}/${m.id}`; + if (existingNames.has(name)) continue; models.push({ - name: `models/${providerId}/${m.id}`, + name, displayName: m.name || m.id, ...(typeof m.description === "string" ? { description: m.description } : {}), supportedGenerationMethods: ["generateContent"], @@ -100,6 +146,7 @@ export async function GET() { ? { thinking: true } : {}), }); + existingNames.add(name); } } } catch { diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 0aea042b83..6bb17a8a71 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -21,7 +21,7 @@ import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry. type JsonRecord = Record; -type BuilderModelSource = "api-sync" | "system" | "custom" | "fallback"; +type BuilderModelSource = "imported" | "system" | "custom" | "fallback"; type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error"; type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" }; @@ -159,7 +159,7 @@ function isChatCapable(supportedEndpoints: string[] | undefined): boolean { function getSourcePriority(source: BuilderModelSource): number { switch (source) { - case "api-sync": + case "imported": return 0; case "system": return 1; @@ -412,7 +412,7 @@ export async function getComboBuilderOptions(): Promise 0 ? value.trim() : null; +} + function getKeyValue(row: unknown): { key: string | null; value: string | null } { const record = asRecord(row); return { @@ -378,7 +382,7 @@ export async function addCustomModel( } /** - * Replace the entire custom models list for a provider (used by auto-sync). + * Replace the entire custom models list for a provider. * Preserves per-model compatibility overrides for models that still exist. */ export async function replaceCustomModels( @@ -397,7 +401,7 @@ export async function replaceCustomModels( { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { // Guard: skip destructive clear when the caller hasn't explicitly opted in. - // This prevents auto-sync from wiping manually-imported models when the + // This prevents callers from wiping manually added models when the // upstream /models endpoint fails, times out, or returns an empty list. if (models.length === 0 && !allowEmpty) { const existing = await getCustomModels(providerId); @@ -520,7 +524,7 @@ export async function removeCustomModel(providerId: string, modelId: string) { export interface SyncedAvailableModel { id: string; name: string; - source: "api-sync"; + source: "imported"; supportedEndpoints?: string[]; inputTokenLimit?: number; outputTokenLimit?: number; @@ -528,6 +532,57 @@ export interface SyncedAvailableModel { supportsThinking?: boolean; } +type SyncedAvailableModelInput = Omit & { + source?: string; +}; + +function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null { + const record = asRecord(model); + const id = + toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model); + if (!id) return null; + + const name = + toNonEmptyString(record.name) || + toNonEmptyString(record.displayName) || + toNonEmptyString(record.model) || + id; + const supportedEndpoints = Array.isArray(record.supportedEndpoints) + ? Array.from( + new Set( + record.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : undefined; + + return { + id, + name, + source: "imported", + ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), + ...(typeof record.inputTokenLimit === "number" + ? { inputTokenLimit: record.inputTokenLimit } + : {}), + ...(typeof record.outputTokenLimit === "number" + ? { outputTokenLimit: record.outputTokenLimit } + : {}), + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(record.supportsThinking === true ? { supportsThinking: true } : {}), + }; +} + +function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] { + if (!Array.isArray(models)) return []; + const deduped = new Map(); + for (const model of models) { + const normalized = normalizeSyncedAvailableModel(model); + if (normalized) deduped.set(normalized.id, normalized); + } + return Array.from(deduped.values()); +} + /** * Get synced available models for a specific provider connection. */ @@ -544,7 +599,7 @@ export async function getSyncedAvailableModelsForConnection( if (!value) return []; try { const models = JSON.parse(value); - return Array.isArray(models) ? models : []; + return normalizeSyncedAvailableModels(models); } catch { return []; } @@ -566,7 +621,7 @@ export async function getSyncedAvailableModels( for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; - const models: SyncedAvailableModel[] = JSON.parse(value); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); for (const m of models) { if (m.id) map.set(m.id, m); } @@ -591,7 +646,7 @@ export async function getAllSyncedAvailableModels(): Promise< if (!key || value === null) continue; const providerId = key.split(":")[0]; if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); - const models: SyncedAvailableModel[] = JSON.parse(value); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); const map = byProvider.get(providerId)!; for (const m of models) { if (m.id) map.set(m.id, m); @@ -611,18 +666,19 @@ export async function getAllSyncedAvailableModels(): Promise< export async function replaceSyncedAvailableModelsForConnection( providerId: string, connectionId: string, - models: SyncedAvailableModel[] + models: SyncedAvailableModelInput[] ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; - if (models.length === 0) { + const normalizedModels = normalizeSyncedAvailableModels(models); + if (normalizedModels.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run( key ); } else { db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" - ).run(key, JSON.stringify(models)); + ).run(key, JSON.stringify(normalizedModels)); } backupDbFile("pre-write"); // Return the full unioned list for the provider diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index 41a2555cec..227d96f9ae 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -1,7 +1,10 @@ import { getCustomModels, + getSyncedAvailableModelsForConnection, + mergeModelCompatOverride, replaceCustomModels, replaceSyncedAvailableModelsForConnection, + type ModelCompatPatch, type SyncedAvailableModel, } from "@/lib/db/models"; import { @@ -9,7 +12,6 @@ import { usesManagedAvailableModels, } from "@/lib/providerModels/managedAvailableModels"; import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; -import { getModelsByProviderId } from "@/shared/constants/models"; type JsonRecord = Record; @@ -18,7 +20,7 @@ export type ManagedModelImportMode = "merge" | "sync"; export type ManagedImportedModel = { id: string; name: string; - source: "api-sync"; + source: "imported"; apiFormat: "chat-completions"; supportedEndpoints?: string[]; inputTokenLimit?: number; @@ -34,42 +36,39 @@ function toNonEmptyString(value: unknown): string | null { function normalizeManagedSource(source: unknown): string { const normalized = toNonEmptyString(source)?.toLowerCase(); if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") { - return "api-sync"; + return "imported"; } return normalized || "manual"; } -function normalizeImportedModels( - providerId: string, - fetchedModels: unknown -): ManagedImportedModel[] { +function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] { const discovered = normalizeDiscoveredModels(fetchedModels); - const registryIds = new Set(getModelsByProviderId(providerId).map((model: any) => model.id)); - return discovered - .filter((model) => !registryIds.has(model.id)) - .map((model) => ({ - id: model.id, - name: model.name || model.id, - source: "api-sync", - apiFormat: "chat-completions", - ...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0 - ? { supportedEndpoints: model.supportedEndpoints } - : {}), - ...(typeof model.inputTokenLimit === "number" - ? { inputTokenLimit: model.inputTokenLimit } - : {}), - ...(typeof model.outputTokenLimit === "number" - ? { outputTokenLimit: model.outputTokenLimit } - : {}), - ...(typeof model.description === "string" ? { description: model.description } : {}), - ...(model.supportsThinking === true ? { supportsThinking: true } : {}), - })); + return discovered.map((model) => ({ + id: model.id, + name: model.name || model.id, + source: "imported", + apiFormat: "chat-completions", + ...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0 + ? { supportedEndpoints: model.supportedEndpoints } + : {}), + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(model.supportsThinking === true ? { supportsThinking: true } : {}), + })); } -function isManagedDiscoveredSource(source: unknown): boolean { - const normalized = toNonEmptyString(source)?.toLowerCase(); - return normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported"; +function isImportedSource(source: unknown): boolean { + return normalizeManagedSource(source) === "imported"; +} + +function getModelId(model: JsonRecord): string | null { + return toNonEmptyString(model.id); } function summarizeImportedChanges( @@ -86,9 +85,30 @@ function summarizeImportedChanges( const toComparable = (model: JsonRecord | undefined) => { if (!model) return null; + const id = toNonEmptyString(model.id) || ""; + const supportedEndpoints = Array.isArray(model.supportedEndpoints) + ? Array.from( + new Set( + model.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : ["chat"]; return { - ...model, + id, + name: toNonEmptyString(model.name) || id, source: normalizeManagedSource(model.source), + apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions", + supportedEndpoints, + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(model.supportsThinking === true ? { supportsThinking: true } : {}), }; }; @@ -125,37 +145,71 @@ function collectAddedImportedModels( return importedModels.filter((model) => !previousIds.has(model.id)); } +function getCompatPatchFromCustomModel(model: JsonRecord): ModelCompatPatch | null { + const patch: ModelCompatPatch = {}; + + if (typeof model.normalizeToolCallId === "boolean") { + patch.normalizeToolCallId = model.normalizeToolCallId; + } + if (typeof model.preserveOpenAIDeveloperRole === "boolean") { + patch.preserveOpenAIDeveloperRole = model.preserveOpenAIDeveloperRole; + } + if (typeof model.isHidden === "boolean") { + patch.isHidden = model.isHidden; + } + if (model.compatByProtocol && typeof model.compatByProtocol === "object") { + patch.compatByProtocol = model.compatByProtocol as ModelCompatPatch["compatByProtocol"]; + } + if (model.upstreamHeaders && typeof model.upstreamHeaders === "object") { + patch.upstreamHeaders = model.upstreamHeaders as Record; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +function preserveRemovedCustomModelCompat(providerId: string, removedModels: JsonRecord[]) { + for (const model of removedModels) { + const modelId = getModelId(model); + if (!modelId) continue; + const patch = getCompatPatchFromCustomModel(model); + if (!patch) continue; + mergeModelCompatOverride(providerId, modelId, patch); + } +} + export async function importManagedModels({ providerId, connectionId, fetchedModels, mode, + previousSyncedAvailableModels: previousSyncedAvailableModelsInput, }: { providerId: string; connectionId: string; fetchedModels: unknown; mode: ManagedModelImportMode; + previousSyncedAvailableModels?: SyncedAvailableModel[]; }) { const previousModels = (await getCustomModels(providerId)) as JsonRecord[]; - const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels); + const previousSyncedAvailableModels = + previousSyncedAvailableModelsInput ?? + (await getSyncedAvailableModelsForConnection(providerId, connectionId)); + const discoveredModels = normalizeDiscoveredModels(fetchedModels); + const candidateImportedModels = normalizeImportedModels(fetchedModels); const importedIds = new Set(candidateImportedModels.map((model) => model.id)); + const discoveredIds = new Set(discoveredModels.map((model) => model.id)); const nextModelsMap = new Map(); + const removedCustomModels: JsonRecord[] = []; - if (mode === "merge") { - for (const model of previousModels) { - if (model?.id) nextModelsMap.set(String(model.id), model); + for (const model of previousModels) { + const modelId = getModelId(model); + if (!modelId) continue; + if (isImportedSource(model.source) || discoveredIds.has(modelId)) { + removedCustomModels.push(model); + continue; } - } else { - for (const model of previousModels) { - if (!model?.id) continue; - if (isManagedDiscoveredSource(model.source)) continue; - nextModelsMap.set(String(model.id), model); - } - } - - for (const model of candidateImportedModels) { - nextModelsMap.set(model.id, model); + nextModelsMap.set(modelId, model); } const persistedModels = (await replaceCustomModels( @@ -170,11 +224,12 @@ export async function importManagedModels({ outputTokenLimit?: number; description?: string; supportsThinking?: boolean; - }> + }>, + { allowEmpty: true } )) as JsonRecord[]; + preserveRemovedCustomModelCompat(providerId, removedCustomModels); - const discoveredModels = normalizeDiscoveredModels(fetchedModels); - let syncedAvailableModels: SyncedAvailableModel[] = []; + let syncedAvailableModels: SyncedAvailableModel[] = previousSyncedAvailableModels; if (discoveredModels.length > 0) { syncedAvailableModels = await replaceSyncedAvailableModelsForConnection( providerId, @@ -184,24 +239,28 @@ export async function importManagedModels({ } let syncedAliases = 0; - if (usesManagedAvailableModels(providerId)) { + if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) { const aliasSync = await syncManagedAvailableModelAliases( providerId, - mode === "sync" - ? persistedModels - .map((model) => toNonEmptyString(model.id)) - .filter((modelId): modelId is string => Boolean(modelId)) - : candidateImportedModels.map((model) => model.id), + discoveredModels.map((model) => model.id), { pruneMissing: mode === "sync" } ); syncedAliases = aliasSync.assignedAliases.length; } - const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds); - const importedModels = collectAddedImportedModels(previousModels, candidateImportedModels); + const importedChanges = summarizeImportedChanges( + previousSyncedAvailableModels as JsonRecord[], + discoveredModels as JsonRecord[], + importedIds + ); + const importedModels = collectAddedImportedModels( + previousSyncedAvailableModels as JsonRecord[], + candidateImportedModels + ); return { previousModels, + previousSyncedAvailableModels, persistedModels, importedModels, discoveredModels, diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 0ed9dbcca5..89235b566a 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -48,7 +48,7 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel deduped.set(id, { id, name, - source: "api-sync", + source: "imported", ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), ...(typeof record.inputTokenLimit === "number" ? { inputTokenLimit: record.inputTokenLimit } diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 6937a970ad..dd41eadffe 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -9,6 +9,7 @@ import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableMod import { getModelCatalogSourceLabel, matchesModelCatalogQuery, + normalizeModelCatalogSource, } from "@/shared/utils/modelCatalogSearch"; import { OAUTH_PROVIDERS, @@ -144,7 +145,7 @@ export default function ModelSelectModal({ name: cm.name || cm.id, value: `${alias}/${cm.id}`, isCustom: true, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...aliasModels, ...customEntries]; @@ -198,7 +199,7 @@ export default function ModelSelectModal({ name: cm.name || cm.id, value: `${nodePrefix}/${cm.id}`, isCustom: true, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...nodeModels, ...fallbackEntries, ...customEntries]; @@ -231,7 +232,7 @@ export default function ModelSelectModal({ name: cm.name || cm.id, value: `${alias}/${cm.id}`, isCustom: true, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...systemEntries, ...customEntries]; diff --git a/src/shared/utils/modelCatalogSearch.ts b/src/shared/utils/modelCatalogSearch.ts index c842ff6515..7ab5459da2 100644 --- a/src/shared/utils/modelCatalogSearch.ts +++ b/src/shared/utils/modelCatalogSearch.ts @@ -1,4 +1,4 @@ -export type ModelCatalogSource = "system" | "custom" | "api-sync" | "fallback" | "alias"; +export type ModelCatalogSource = "system" | "custom" | "imported" | "fallback" | "alias"; type ModelCatalogTarget = { modelId?: string | null; @@ -20,7 +20,7 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo normalized === "auto-sync" || normalized === "imported" ) { - return "api-sync"; + return "imported"; } if (normalized === "fallback") return "fallback"; if (normalized === "alias") return "alias"; @@ -33,8 +33,8 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo export function getModelCatalogSourceLabel(source?: string | null): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": - return "Synced"; + case "imported": + return "Imported"; case "custom": return "Custom"; case "fallback": @@ -49,7 +49,7 @@ export function getModelCatalogSourceLabel(source?: string | null): string { function getModelCatalogSourceSearchText(source?: string | null): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": + case "imported": return "synced api imported discovered"; case "custom": return "custom manual imported"; diff --git a/tests/unit/db-models-crud.test.ts b/tests/unit/db-models-crud.test.ts index 7f8f63f76c..bd21ad6aca 100644 --- a/tests/unit/db-models-crud.test.ts +++ b/tests/unit/db-models-crud.test.ts @@ -115,7 +115,7 @@ test("replaceCustomModels preserves compat fields and respects the empty-list gu { id: "gpt-4.1", name: "GPT-4.1 Refreshed", - source: "api-sync", + source: "imported", supportsThinking: true, }, ]); @@ -147,12 +147,12 @@ test("removing a custom model also removes its compat override", async () => { test("synced available models are unioned across connections and cleaned per connection", async () => { await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-a", [ - { id: "gpt-4.1", name: "GPT-4.1", source: "api-sync" }, - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" }, + { id: "gpt-4.1", name: "GPT-4.1", source: "imported" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" }, ]); const union = await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-b", [ - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" }, - { id: "o3-mini", name: "o3-mini", source: "api-sync" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" }, + { id: "o3-mini", name: "o3-mini", source: "imported" }, ]); const remaining = await modelsDb.deleteSyncedAvailableModelsForConnection("openai", "conn-a"); const allProviders = await modelsDb.getAllSyncedAvailableModels(); diff --git a/tests/unit/model-catalog-search.test.ts b/tests/unit/model-catalog-search.test.ts index 00c6d45c79..cd1606ea50 100644 --- a/tests/unit/model-catalog-search.test.ts +++ b/tests/unit/model-catalog-search.test.ts @@ -9,8 +9,8 @@ import { test("model catalog source normalization groups manual and synced rows separately", () => { assert.equal(normalizeModelCatalogSource("manual"), "custom"); - assert.equal(normalizeModelCatalogSource("imported"), "api-sync"); - assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync"); + assert.equal(normalizeModelCatalogSource("imported"), "imported"); + assert.equal(normalizeModelCatalogSource("api-sync"), "imported"); assert.equal(normalizeModelCatalogSource("fallback"), "fallback"); assert.equal(normalizeModelCatalogSource("alias"), "alias"); assert.equal(normalizeModelCatalogSource(undefined), "system"); @@ -19,7 +19,7 @@ test("model catalog source normalization groups manual and synced rows separatel test("model catalog source labels stay user-facing", () => { assert.equal(getModelCatalogSourceLabel("system"), "Built-in"); assert.equal(getModelCatalogSourceLabel("custom"), "Custom"); - assert.equal(getModelCatalogSourceLabel("api-sync"), "Synced"); + assert.equal(getModelCatalogSourceLabel("imported"), "Imported"); assert.equal(getModelCatalogSourceLabel("fallback"), "Fallback"); assert.equal(getModelCatalogSourceLabel("alias"), "Alias"); }); @@ -29,7 +29,7 @@ test("model catalog query matches id, display name, alias and source label", () modelId: "qwen/qwen3-coder-480b-a35b-instruct", modelName: "Qwen3 Coder 480B", alias: "best-qwen", - source: "api-sync", + source: "imported", }; assert.equal(matchesModelCatalogQuery("", target), true); diff --git a/tests/unit/model-catalog-source.test.ts b/tests/unit/model-catalog-source.test.ts index a56ce76a7d..50e0461c69 100644 --- a/tests/unit/model-catalog-source.test.ts +++ b/tests/unit/model-catalog-source.test.ts @@ -5,9 +5,9 @@ const { getModelCatalogSourceLabel, normalizeModelCatalogSource } = await import("../../src/shared/utils/modelCatalogSearch.ts"); test("model catalog source normalizes synced import variants consistently", () => { - assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync"); - assert.equal(normalizeModelCatalogSource("auto-sync"), "api-sync"); - assert.equal(normalizeModelCatalogSource("imported"), "api-sync"); - assert.equal(getModelCatalogSourceLabel("auto-sync"), "Synced"); - assert.equal(getModelCatalogSourceLabel("imported"), "Synced"); + assert.equal(normalizeModelCatalogSource("api-sync"), "imported"); + assert.equal(normalizeModelCatalogSource("imported"), "imported"); + assert.equal(normalizeModelCatalogSource("auto-sync"), "imported"); + assert.equal(getModelCatalogSourceLabel("auto-sync"), "Imported"); + assert.equal(getModelCatalogSourceLabel("imported"), "Imported"); }); diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index 1a2b4b085b..6d5f8aff1d 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -53,11 +53,11 @@ test("model sync route skips success log when fetched models do not change store apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "custom-model-1", name: "Custom Model 1", - source: "auto-sync", + source: "imported", }, ]); @@ -85,6 +85,7 @@ test("model sync route skips success log when fetched models do not change store const body = (await response.json()) as any; assert.equal(body.logged, false); assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); + assert.deepEqual(body.models, []); const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(logs.length, 0); @@ -250,6 +251,45 @@ test("model sync route falls back to the upstream HTTP status when the models pa assert.equal(logs[0].error, "HTTP 429"); }); +test("model sync route reports invalid JSON /models responses without losing upstream status", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "Invalid JSON Sync", + apiKey: "test-key", + }); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return new Response("bad gateway", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }; + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + const body = (await response.json()) as any; + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + + assert.equal(response.status, 502); + assert.equal(body.error, "Invalid JSON response from /models"); + assert.equal(body.upstreamStatus, 200); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, 200); + assert.equal(logs[0].error, "Invalid JSON response from /models"); +}); + test("model sync route preserves previously synced models when the upstream omits the models list", async () => { await resetStorage(); @@ -260,11 +300,11 @@ test("model sync route preserves previously synced models when the upstream omit apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "persisted-model", name: "Persisted Model", - source: "auto-sync", + source: "imported", }, ]); @@ -290,13 +330,12 @@ test("model sync route preserves previously synced models when the upstream omit assert.equal(body.syncedModels, 1); assert.equal(body.logged, false); assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); - assert.deepEqual(body.models, [ + assert.deepEqual(body.models, []); + assert.deepEqual(await modelsDb.getSyncedAvailableModels("openrouter"), [ { id: "persisted-model", name: "Persisted Model", - source: "auto-sync", - apiFormat: "chat-completions", - supportedEndpoints: ["chat"], + source: "imported", }, ]); assert.equal(logs.length, 0); @@ -348,24 +387,12 @@ test("model sync route writes synced available models for Gemini connections", a assert.equal(body.syncedModels, 1); assert.equal(body.logged, true); assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); - assert.deepEqual(body.models, [ - { - id: "gemini-custom-preview", - name: "Gemini Custom Preview", - source: "api-sync", - apiFormat: "chat-completions", - supportedEndpoints: ["chat", "embeddings"], - inputTokenLimit: 32768, - outputTokenLimit: 8192, - description: "Custom Gemini preview model", - supportsThinking: true, - }, - ]); + assert.deepEqual(body.models, []); assert.deepEqual(synced, [ { id: "gemini-custom-preview", name: "Gemini Custom Preview", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat", "embeddings"], inputTokenLimit: 32768, outputTokenLimit: 8192, @@ -421,7 +448,7 @@ test("model sync route writes synced available models for non-Gemini providers t { id: "glm-5.1", name: "GLM 5.1", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat"], inputTokenLimit: 262144, }, @@ -439,6 +466,7 @@ test("model sync route import mode merges discovered models without deleting man }); await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual"); + await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual"); await localDb.setModelAlias("manual-only", "openrouter/manual-only"); globalThis.fetch = async (url) => { @@ -467,16 +495,21 @@ test("model sync route import mode merges discovered models without deleting man assert.equal(body.updatedCount, 0); assert.equal(body.syncedAliases, 1); assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); + assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 }); assert.deepEqual( body.models.map((model) => ({ id: model.id, source: model.source })), - [ - { id: "manual-only", source: "manual" }, - { id: "router-v4", source: "api-sync" }, - ] + [{ id: "manual-only", source: "manual" }] ); assert.deepEqual( body.importedModels.map((model) => ({ id: model.id, source: model.source })), - [{ id: "router-v4", source: "api-sync" }] + [{ id: "router-v4", source: "imported" }] + ); + assert.deepEqual( + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ + id: model.id, + source: model.source, + })), + [{ id: "router-v4", source: "imported" }] ); assert.equal(aliases["manual-only"], "openrouter/manual-only"); assert.equal(aliases["router-v4"], "openrouter/router-v4"); @@ -492,12 +525,11 @@ test("model sync route import mode ignores supported endpoint ordering changes", apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "router-v4", name: "Router V4", - source: "api-sync", - apiFormat: "chat-completions", + source: "imported", supportedEndpoints: ["chat", "embeddings"], }, ]); @@ -536,12 +568,13 @@ test("model sync route import mode ignores supported endpoint ordering changes", assert.equal(body.logged, false); assert.deepEqual(body.importedModels, []); assert.deepEqual( - body.models.map((model) => ({ + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ id: model.id, supportedEndpoints: model.supportedEndpoints, })), [{ id: "router-v4", supportedEndpoints: ["chat", "embeddings"] }] ); + assert.deepEqual(body.models, []); assert.equal(logs.length, 0); }); @@ -555,12 +588,11 @@ test("model sync route import mode reports updates without counting them as new apiKey: "test-key", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "router-v4", name: "Router V4", - source: "api-sync", - apiFormat: "chat-completions", + source: "imported", supportedEndpoints: ["chat"], }, ]); @@ -597,7 +629,7 @@ test("model sync route import mode reports updates without counting them as new assert.deepEqual(body.importedChanges, { added: 0, updated: 1, unchanged: 0, total: 1 }); assert.deepEqual(body.importedModels, []); assert.deepEqual( - body.models.map((model) => ({ + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ id: model.id, name: model.name, supportedEndpoints: model.supportedEndpoints, @@ -610,6 +642,7 @@ test("model sync route import mode reports updates without counting them as new }, ] ); + assert.deepEqual(body.models, []); assert.equal(body.logged, true); assert.equal(logs.length, 1); }); @@ -624,16 +657,16 @@ test("model sync route records added, removed, and updated model diffs with fall accessToken: "sync-token", }); - await modelsDb.replaceCustomModels("openrouter", [ + await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [ { id: "persisted-model", name: "Persisted Model", - source: "auto-sync", + source: "imported", }, { id: "removed-model", name: "Removed Model", - source: "auto-sync", + source: "imported", }, ]); @@ -673,7 +706,7 @@ test("model sync route records added, removed, and updated model diffs with fall assert.equal(body.logged, true); assert.deepEqual(body.modelChanges, { added: 1, removed: 1, updated: 1, total: 3 }); assert.deepEqual( - body.models.map((model) => ({ + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ id: model.id, name: model.name, supportedEndpoints: model.supportedEndpoints, @@ -689,7 +722,7 @@ test("model sync route records added, removed, and updated model diffs with fall { id: "fallback-model", name: "Fallback Model", - supportedEndpoints: ["chat"], + supportedEndpoints: undefined, description: "Fallback from model field", }, ] @@ -753,17 +786,12 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo assert.equal(body.provider, "openrouter"); assert.equal(body.syncedModels, 3); assert.equal(body.availableModelsCount, 3); - assert.equal(body.syncedAliases, 2); + assert.equal(body.syncedAliases, 3); assert.equal(body.logged, true); - assert.deepEqual(body.modelChanges, { added: 2, removed: 0, updated: 0, total: 2 }); - assert.deepEqual( - body.models.map((model) => ({ id: model.id, name: model.name })), - [ - { id: "router-v2", name: "Router V2" }, - { id: "router-v3", name: "Router V3" }, - ] - ); + assert.deepEqual(body.modelChanges, { added: 3, removed: 0, updated: 0, total: 3 }); + assert.deepEqual(body.models, []); assert.equal(aliases["stale-model"], undefined); + assert.equal(aliases["auto"], "openrouter/auto"); assert.equal(aliases["openrouter-router-v2"], "openrouter/router-v2"); assert.equal(aliases["router-v3"], "openrouter/router-v3"); assert.equal(logs.length, 1); @@ -782,6 +810,7 @@ test("model sync route reports synced managed models separately from preserved m }); await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual"); + await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual"); globalThis.fetch = async (url) => { assert.equal( @@ -807,12 +836,17 @@ test("model sync route reports synced managed models separately from preserved m assert.equal(body.availableModelsCount, 2); assert.equal(body.importedCount, 1); assert.equal(body.updatedCount, 0); + assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 }); assert.deepEqual( body.models.map((model) => ({ id: model.id, source: model.source })), - [ - { id: "manual-only", source: "manual" }, - { id: "router-v4", source: "api-sync" }, - ] + [{ id: "manual-only", source: "manual" }] + ); + assert.deepEqual( + (await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({ + id: model.id, + source: model.source, + })), + [{ id: "router-v4", source: "imported" }] ); }); @@ -868,33 +902,71 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi assert.equal(aliases["cm-sonnet-4-6"], "anthropic-compatible-demo/sonnet-4-6"); }); -test("model sync route returns 500 and records a failure when the internal models fetch throws", async () => { +test("model sync route falls back to in-process discovery when internal self-fetch throws", async () => { await resetStorage(); const connection = await providersDb.createProviderConnection({ - provider: "openrouter", + provider: "openai-compatible-aio", authType: "apikey", - name: "Exploding Sync", + name: "AIO Import", apiKey: "test-key", + providerSpecificData: { + prefix: "aio", + apiType: "chat", + baseUrl: "https://api.bltcy.ai/v1", + nodeName: "aio", + autoSync: true, + }, }); - globalThis.fetch = async () => { - throw new Error("network exploded"); + const fetchCalls: string[] = []; + globalThis.fetch = async (url) => { + const urlString = String(url); + fetchCalls.push(urlString); + + if (urlString === `http://localhost/api/providers/${connection.id}/models?refresh=true`) { + throw new Error("fetch failed"); + } + + assert.equal(urlString, "https://api.bltcy.ai/v1/models"); + return Response.json({ + data: [{ id: "aio-model", name: "AIO Model" }], + }); }; const response = await modelSyncRoute.POST( - new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + new Request(`http://localhost/api/providers/${connection.id}/sync-models?mode=import`, { method: "POST", headers: scheduler.buildModelSyncInternalHeaders(), }), { params: { id: connection.id } } ); - const body = (await response.json()) as any; - const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + const body = (await response.json()) as { + importedCount: number; + importedModels: Array<{ id: string; source: string }>; + }; + const customModels = (await modelsDb.getCustomModels("openai-compatible-aio")) as Array<{ + id: string; + source: string; + }>; + const availableModels = await modelsDb.getSyncedAvailableModels("openai-compatible-aio"); - assert.equal(response.status, 500); - assert.equal(body.error, "network exploded"); - assert.equal(logs.length, 1); - assert.equal(logs[0].status, 500); - assert.equal(logs[0].provider, "openrouter"); + assert.equal(response.status, 200); + assert.equal(body.importedCount, 1); + assert.deepEqual( + body.importedModels.map((model) => ({ id: model.id, source: model.source })), + [{ id: "aio-model", source: "imported" }] + ); + assert.deepEqual( + customModels.map((model) => ({ id: model.id, source: model.source })), + [] + ); + assert.deepEqual( + availableModels.map((model) => ({ id: model.id, source: model.source })), + [{ id: "aio-model", source: "imported" }] + ); + assert.deepEqual(fetchCalls, [ + `http://localhost/api/providers/${connection.id}/models?refresh=true`, + "https://api.bltcy.ai/v1/models", + ]); }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 8d883df60f..234b7e2d33 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -354,21 +354,21 @@ test("v1 models catalog includes synced Gemini models and duplicates audio model { id: "gemini-audio-live", name: "Gemini Audio Live", - source: "api-sync", + source: "imported", supportedEndpoints: ["audio"], inputTokenLimit: 4096, }, { id: "text-embedding-004", name: "Text Embedding 004", - source: "api-sync", + source: "imported", supportedEndpoints: ["embeddings"], inputTokenLimit: 2048, }, { id: "gemini-hidden", name: "Gemini Hidden", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat"], }, ] @@ -402,7 +402,7 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a { id: "gemini-2.5-pro-live", name: "Gemini 2.5 Pro Live", - source: "api-sync", + source: "imported", inputTokenLimit: 8192, }, ]); @@ -430,7 +430,7 @@ test("v1 models catalog includes synced non-Gemini provider models from discover { id: "glm-5.1", name: "GLM 5.1", - source: "api-sync", + source: "imported", supportedEndpoints: ["chat"], inputTokenLimit: 262144, }, diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 996f98af89..09ea2a6cc9 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -461,7 +461,7 @@ test("provider models route caches discovered opencode-go models per connection" assert.equal(firstResponse.status, 200); assert.equal(firstBody.source, "api"); assert.deepEqual(firstBody.models, [{ id: "glm-5.1", name: "GLM 5.1" }]); - assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]); + assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]); globalThis.fetch = async () => { throw new Error("cached route should not hit upstream"); @@ -472,7 +472,7 @@ test("provider models route caches discovered opencode-go models per connection" assert.equal(cachedResponse.status, 200); assert.equal(cachedBody.source, "cache"); - assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]); + assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]); assert.equal(fetchCalls, 1); }); @@ -481,7 +481,7 @@ test("provider models route falls back to cached models when a refresh fails", a apiKey: "opencode-go-key", }); await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [ - { id: "cached-go", name: "Cached Go", source: "api-sync" }, + { id: "cached-go", name: "Cached Go", source: "imported" }, ]); let fetchCalls = 0; @@ -496,7 +496,7 @@ test("provider models route falls back to cached models when a refresh fails", a assert.equal(response.status, 200); assert.equal(body.source, "cache"); assert.match(body.warning, /cached catalog/i); - assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "api-sync" }]); + assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "imported" }]); assert.equal(fetchCalls, 1); }); @@ -505,7 +505,7 @@ test("provider models route clears cached discovery when a refresh returns no re apiKey: "opencode-go-key", }); await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [ - { id: "cached-go", name: "Cached Go", source: "api-sync" }, + { id: "cached-go", name: "Cached Go", source: "imported" }, ]); globalThis.fetch = async () => { diff --git a/tests/unit/v1beta-models-route.test.ts b/tests/unit/v1beta-models-route.test.ts new file mode 100644 index 0000000000..21f6b210b4 --- /dev/null +++ b/tests/unit/v1beta-models-route.test.ts @@ -0,0 +1,55 @@ +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-v1beta-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "v1beta-models-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const v1betaModelsRoute = await import("../../src/app/api/v1beta/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1beta models route deduplicates custom models against built-in and synced entries", async () => { + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-main", [ + { + id: "gpt-4o", + name: "GPT-4o From Sync", + source: "imported", + }, + { + id: "review-sync-only", + name: "Review Sync Only", + source: "imported", + }, + ]); + await modelsDb.addCustomModel("openai", "gpt-4o", "GPT-4o Manual Duplicate"); + await modelsDb.addCustomModel("openai", "review-sync-only", "Review Manual Duplicate"); + await modelsDb.addCustomModel("openai", "review-manual-only", "Review Manual Only"); + + const response = await v1betaModelsRoute.GET(); + const body = (await response.json()) as { models: Array<{ name: string }> }; + const names = body.models.map((model) => model.name); + + assert.equal(response.status, 200); + assert.equal(names.filter((name) => name === "models/openai/gpt-4o").length, 1); + assert.equal(names.filter((name) => name === "models/openai/review-sync-only").length, 1); + assert.equal(names.filter((name) => name === "models/openai/review-manual-only").length, 1); +});