From fff025ca0f7d4b27ab2fe7ac27bfd181182dc882 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:14:59 -0400 Subject: [PATCH] Fix OpenRouter remote discovery and unify managed model sync (#1521) Integrated into release/v3.7.0 --- .../dashboard/providers/[id]/page.tsx | 141 ++++------- src/app/api/providers/[id]/models/route.ts | 16 +- .../api/providers/[id]/sync-models/route.ts | 110 ++++---- .../providerModels/managedAvailableModels.ts | 20 +- src/lib/providerModels/managedModelImport.ts | 212 ++++++++++++++++ src/lib/providerModels/modelDiscovery.ts | 2 +- src/shared/utils/modelCatalogSearch.ts | 11 +- tests/unit/model-catalog-source.test.ts | 13 + tests/unit/model-sync-route.test.ts | 236 +++++++++++++++++- tests/unit/provider-models-route.test.ts | 24 ++ 10 files changed, 626 insertions(+), 159 deletions(-) create mode 100644 src/lib/providerModels/managedModelImport.ts create mode 100644 tests/unit/model-catalog-source.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 13e7c1087f..11370b1d42 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -412,10 +412,7 @@ interface CompatibleModelsSectionProps { onDeleteAlias: (alias: string) => void; connections: { id?: string; isActive?: boolean }[]; isAnthropic?: boolean; - onImportWithProgress: ( - fetchModels: () => Promise<{ models: unknown[] }>, - processModel: (model: unknown) => Promise - ) => Promise; + onImportWithProgress: (connectionId: string) => Promise; t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; @@ -1356,10 +1353,16 @@ export default function ProviderDetailPage() { } const syncedCount = syncData.syncedModels || 0; + const availableCount = + typeof syncData.availableModelsCount === "number" + ? syncData.availableModelsCount + : Array.isArray(syncData.models) + ? syncData.models.length + : syncedCount; const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || []; const logs: string[] = []; if (syncedModelList.length > 0) { - logs.push(`✓ ${syncedCount} models available`); + logs.push(`✓ ${availableCount} models available`); logs.push(""); for (const m of syncedModelList) { logs.push(` ${m.name || m.id}`); @@ -1369,10 +1372,10 @@ export default function ProviderDetailPage() { setImportProgress((prev) => ({ ...prev, phase: "done", - status: t("modelsImported", { count: syncedCount }), - total: syncedCount, - current: syncedCount, - importedCount: syncedCount, + status: t("modelsImported", { count: availableCount }), + total: availableCount, + current: availableCount, + importedCount: availableCount, logs, })); @@ -2004,10 +2007,7 @@ export default function ProviderDetailPage() { }; // Shared import handler for CompatibleModelsSection - const handleCompatibleImportWithProgress = async ( - fetchModels: () => Promise<{ models: any[] }>, - processModel: (model: any) => Promise - ) => { + const handleCompatibleImportWithProgress = async (connectionId: string) => { setShowImportModal(true); setImportProgress({ current: 0, @@ -2020,53 +2020,60 @@ export default function ProviderDetailPage() { }); try { - const data = await fetchModels(); - const models = data.models || []; - if (models.length === 0) { + const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, { + method: "POST", + signal: AbortSignal.timeout(60_000), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("failedImportModels")); + } + + const importedModels = Array.isArray(data.importedModels) ? data.importedModels : []; + const importedCount = + typeof data.importedCount === "number" ? data.importedCount : importedModels.length; + const changedCount = + typeof data.importedChanges?.total === "number" + ? data.importedChanges.total + : importedCount; + + if (importedModels.length === 0) { setImportProgress((prev) => ({ ...prev, phase: "done", - status: t("noModelsFound"), - logs: [t("noModelsReturnedFromEndpoint")], + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, })); + if (changedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } return; } - setImportProgress((prev) => ({ - ...prev, - phase: "importing", - total: models.length, - status: t("importingModelsProgress", { current: 0, total: models.length }), - logs: [t("foundModelsStartingImport", { count: models.length })], - })); - - let importedCount = 0; - for (let i = 0; i < models.length; i++) { - const model = models[i]; - const modelId = model.id || model.name || model.model; - if (!modelId) continue; - - setImportProgress((prev) => ({ - ...prev, - current: i + 1, - status: t("importingModelsProgress", { current: i + 1, total: models.length }), - logs: [...prev.logs, t("importingModelById", { modelId })], - })); - - const added = await processModel(model); - if (added) importedCount += 1; - } - setImportProgress((prev) => ({ ...prev, phase: "done", - current: models.length, + total: importedModels.length, + current: importedModels.length, status: importedCount > 0 ? t("importSuccessCount", { count: importedCount }) : t("noNewModelsAdded"), logs: [ - ...prev.logs, + t("foundModelsStartingImport", { count: importedModels.length }), + ...importedModels.map((model: any) => + t("importingModelById", { modelId: model.id || model.name || model.model }) + ), importedCount > 0 ? t("importDoneCount", { count: importedCount }) : t("noNewModelsAdded"), @@ -2074,7 +2081,7 @@ export default function ProviderDetailPage() { importedCount, })); - if (importedCount > 0) { + if (changedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); @@ -4493,49 +4500,11 @@ function CompatibleModelsSection({ const handleImport = async () => { if (!allowImport || importing) return; const activeConnection = connections.find((conn) => conn.isActive !== false); - if (!activeConnection) return; + if (!activeConnection?.id) return; setImporting(true); try { - const workingAliases = { ...modelAliases }; - await onImportWithProgress( - // fetchModels callback - async () => { - const res = await fetch(`/api/providers/${activeConnection.id}/models?refresh=true`); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || t("failedImportModels")); - return data; - }, - // processModel callback - async (model: any) => { - const modelId = model.id || model.name || model.model; - if (!modelId) return false; - const resolvedAlias = resolveAlias(modelId, workingAliases); - if (!resolvedAlias) return false; - - // Save to customModels DB FIRST - only create alias if this succeeds - const customModelRes = await fetch("/api/provider-models", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider: providerStorageAlias, - modelId, - modelName: model.name || modelId, - source: "imported", - }), - }); - - if (!customModelRes.ok) { - notify.error(t("failedSaveImportedModel")); - return false; - } - - // Only create alias after customModel is saved successfully - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); - workingAliases[resolvedAlias] = `${providerStorageAlias}/${modelId}`; - return true; - } - ); + await onImportWithProgress(activeConnection.id); } catch (error) { console.error("Error importing models:", error); notify.error(t("failedImportModelsTryAgain")); diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index e13810da3f..12ba06e398 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -986,9 +986,14 @@ export async function GET( return buildApiDiscoveryResponse(models); } + const config = + provider in PROVIDER_MODELS_CONFIG + ? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG] + : undefined; + // Static model providers (no remote /models API) const staticModels = getStaticModelsForProvider(provider); - if (staticModels) { + if (!config && staticModels) { return buildResponse({ provider, connectionId, @@ -1011,11 +1016,10 @@ export async function GET( }); } - const config = - provider in PROVIDER_MODELS_CONFIG - ? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG] - : undefined; - const localCatalog = getStaticModelsForProvider(provider) || PROVIDER_MODELS[provider] || []; + const localCatalog = + (config + ? PROVIDER_MODELS[provider] || staticModels + : staticModels || PROVIDER_MODELS[provider]) || []; if (!config && localCatalog.length > 0) { return buildResponse({ provider, diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 808cbf8ace..9844deeb4d 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,22 +1,15 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; import { - getCustomModels, - replaceCustomModels, - replaceSyncedAvailableModelsForConnection, -} from "@/lib/db/models"; -import { - syncManagedAvailableModelAliases, - usesManagedAvailableModels, -} from "@/lib/providerModels/managedAvailableModels"; -import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; + importManagedModels, + type ManagedModelImportMode, +} from "@/lib/providerModels/managedModelImport"; import { saveCallLog } from "@/lib/usage/callLogs"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { buildModelSyncInternalHeaders, isModelSyncInternalRequest, } from "@/shared/services/modelSyncScheduler"; -import { getModelsByProviderId } from "@/shared/constants/models"; type JsonRecord = Record; @@ -32,7 +25,11 @@ function normalizeModelForComparison(model: unknown) { const record = asRecord(model); const id = toNonEmptyString(record.id) || ""; const name = toNonEmptyString(record.name) || id; - const source = toNonEmptyString(record.source) || "auto-sync"; + const rawSource = toNonEmptyString(record.source)?.toLowerCase(); + const source = + rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported" + ? "api-sync" + : rawSource || "auto-sync"; const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions"; const supportedEndpoints = Array.isArray(record.supportedEndpoints) ? Array.from( @@ -53,6 +50,12 @@ function normalizeModelForComparison(model: unknown) { }; } +function isManagedSyncedModel(model: unknown) { + const record = asRecord(model); + const source = toNonEmptyString(record.source)?.toLowerCase(); + return source === "api-sync" || source === "auto-sync" || source === "imported"; +} + function summarizeModelChanges(previousModels: unknown, nextModels: unknown) { const previousList = Array.isArray(previousModels) ? previousModels : []; const nextList = Array.isArray(nextModels) ? nextModels : []; @@ -129,6 +132,9 @@ function getModelSyncChannelLabel(connection: unknown) { export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const start = Date.now(); const { id } = await params; + const mode = ( + new URL(request.url).searchParams.get("mode") === "import" ? "merge" : "sync" + ) as ManagedModelImportMode; let logProvider = "unknown"; let channelLabel: string | null = null; @@ -192,48 +198,32 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: } const fetchedModels = modelsData.models || []; + const { + previousModels, + persistedModels, + importedModels, + discoveredModels, + syncedAliases, + importedChanges, + } = await importManagedModels({ + providerId: logProvider, + connectionId: id, + fetchedModels, + mode, + }); - // Filter out models already in the built-in registry - const registryIds = new Set(getModelsByProviderId(logProvider).map((m: any) => m.id)); - - // Replace the full model list - const models = fetchedModels - .map((m: any) => ({ - id: m.id || m.name || m.model, - name: m.name || m.displayName || m.id || m.model, - source: "auto-sync", - ...(Array.isArray(m.supportedEndpoints) && m.supportedEndpoints.length > 0 - ? { supportedEndpoints: m.supportedEndpoints } - : {}), - ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), - ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), - ...(typeof m.description === "string" ? { description: m.description } : {}), - ...(m.supportsThinking === true ? { supportsThinking: true } : {}), - })) - .filter((m: any) => m.id && !registryIds.has(m.id)); - - const previousModels = await getCustomModels(logProvider); - const replaced = await replaceCustomModels(logProvider, models); - - try { - const syncedModels = normalizeDiscoveredModels(fetchedModels); - if (syncedModels.length > 0) { - await replaceSyncedAvailableModelsForConnection(logProvider, id, syncedModels); - } - } catch (e) { - console.error(`Failed to union synced available models for ${logProvider}:`, e); - } - - const modelChanges = summarizeModelChanges(previousModels, replaced); - - let syncedAliases = 0; - if (usesManagedAvailableModels(logProvider)) { - const aliasSync = await syncManagedAvailableModelAliases( - logProvider, - models.map((model: any) => model.id) - ); - syncedAliases = aliasSync.assignedAliases.length; - } + const modelChanges = summarizeModelChanges(previousModels, persistedModels); + const syncedModelsCount = + discoveredModels.length > 0 + ? discoveredModels.length + : persistedModels.filter((model) => isManagedSyncedModel(model)).length; + const availableModelsCount = new Set( + [...persistedModels, ...discoveredModels] + .map((model) => toNonEmptyString(asRecord(model).id)) + .filter((modelId): modelId is string => Boolean(modelId)) + ).size; + const importedCount = importedChanges.added; + const updatedCount = importedChanges.updated; if (modelChanges.total > 0) { await saveCallLog({ @@ -247,11 +237,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: duration: Date.now() - start, requestType: "model-sync", responseBody: { - syncedModels: models.length, + syncedModels: syncedModelsCount, + availableModelsCount, syncedAliases, provider: logProvider, channel: channelLabel, modelChanges, + importedCount, + updatedCount, + mode, }, }); } @@ -259,11 +253,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ ok: true, provider: logProvider, - syncedModels: replaced.length, + mode, + syncedModels: syncedModelsCount, + availableModelsCount, syncedAliases, modelChanges, + importedCount, + updatedCount, + importedChanges, logged: modelChanges.total > 0, - models: replaced, + models: persistedModels, + importedModels, }); } catch (error: any) { // Log error diff --git a/src/lib/providerModels/managedAvailableModels.ts b/src/lib/providerModels/managedAvailableModels.ts index 152f8b6127..d862435a6f 100644 --- a/src/lib/providerModels/managedAvailableModels.ts +++ b/src/lib/providerModels/managedAvailableModels.ts @@ -34,7 +34,11 @@ async function getProviderDisplayPrefix(providerId: string): Promise { return typeof prefix === "string" && prefix.trim().length > 0 ? prefix.trim() : providerId; } -export async function syncManagedAvailableModelAliases(providerId: string, modelIds: string[]) { +export async function syncManagedAvailableModelAliases( + providerId: string, + modelIds: string[], + { pruneMissing = true }: { pruneMissing?: boolean } = {} +) { const storagePrefix = getProviderStoragePrefix(providerId); const displayPrefix = await getProviderDisplayPrefix(providerId); const existingAliasesRaw = await getModelAliases(); @@ -53,13 +57,15 @@ export async function syncManagedAvailableModelAliases(providerId: string, model const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`)); const removedAliases: string[] = []; - for (const [alias, value] of Object.entries(workingAliases)) { - if (!value.startsWith(`${storagePrefix}/`)) continue; - if (targetFullModels.has(value)) continue; + if (pruneMissing) { + for (const [alias, value] of Object.entries(workingAliases)) { + if (!value.startsWith(`${storagePrefix}/`)) continue; + if (targetFullModels.has(value)) continue; - await deleteModelAlias(alias); - delete workingAliases[alias]; - removedAliases.push(alias); + await deleteModelAlias(alias); + delete workingAliases[alias]; + removedAliases.push(alias); + } } const assignedAliases: string[] = []; diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts new file mode 100644 index 0000000000..41a2555cec --- /dev/null +++ b/src/lib/providerModels/managedModelImport.ts @@ -0,0 +1,212 @@ +import { + getCustomModels, + replaceCustomModels, + replaceSyncedAvailableModelsForConnection, + type SyncedAvailableModel, +} from "@/lib/db/models"; +import { + syncManagedAvailableModelAliases, + usesManagedAvailableModels, +} from "@/lib/providerModels/managedAvailableModels"; +import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; +import { getModelsByProviderId } from "@/shared/constants/models"; + +type JsonRecord = Record; + +export type ManagedModelImportMode = "merge" | "sync"; + +export type ManagedImportedModel = { + id: string; + name: string; + source: "api-sync"; + apiFormat: "chat-completions"; + supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; +}; + +function toNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeManagedSource(source: unknown): string { + const normalized = toNonEmptyString(source)?.toLowerCase(); + if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") { + return "api-sync"; + } + return normalized || "manual"; +} + +function normalizeImportedModels( + providerId: string, + 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 } : {}), + })); +} + +function isManagedDiscoveredSource(source: unknown): boolean { + const normalized = toNonEmptyString(source)?.toLowerCase(); + return normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported"; +} + +function summarizeImportedChanges( + previousModels: JsonRecord[], + nextModels: JsonRecord[], + importedIds: Set +) { + let added = 0; + let updated = 0; + let unchanged = 0; + + const previousMap = new Map(previousModels.map((model) => [String(model.id), model])); + const nextMap = new Map(nextModels.map((model) => [String(model.id), model])); + + const toComparable = (model: JsonRecord | undefined) => { + if (!model) return null; + return { + ...model, + source: normalizeManagedSource(model.source), + }; + }; + + for (const id of importedIds) { + const previous = previousMap.get(id); + const next = nextMap.get(id); + if (!next) continue; + if (!previous) { + added += 1; + continue; + } + if (JSON.stringify(toComparable(previous)) === JSON.stringify(toComparable(next))) { + unchanged += 1; + continue; + } + updated += 1; + } + + return { + added, + updated, + unchanged, + total: added + updated, + }; +} + +function collectAddedImportedModels( + previousModels: JsonRecord[], + importedModels: ManagedImportedModel[] +): ManagedImportedModel[] { + const previousIds = new Set( + previousModels.map((model) => toNonEmptyString(model.id)).filter(Boolean) + ); + return importedModels.filter((model) => !previousIds.has(model.id)); +} + +export async function importManagedModels({ + providerId, + connectionId, + fetchedModels, + mode, +}: { + providerId: string; + connectionId: string; + fetchedModels: unknown; + mode: ManagedModelImportMode; +}) { + const previousModels = (await getCustomModels(providerId)) as JsonRecord[]; + const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels); + const importedIds = new Set(candidateImportedModels.map((model) => model.id)); + + const nextModelsMap = new Map(); + + if (mode === "merge") { + for (const model of previousModels) { + if (model?.id) nextModelsMap.set(String(model.id), model); + } + } 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); + } + + const persistedModels = (await replaceCustomModels( + providerId, + Array.from(nextModelsMap.values()) as Array<{ + id: string; + name?: string; + source?: string; + apiFormat?: string; + supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; + }> + )) as JsonRecord[]; + + const discoveredModels = normalizeDiscoveredModels(fetchedModels); + let syncedAvailableModels: SyncedAvailableModel[] = []; + if (discoveredModels.length > 0) { + syncedAvailableModels = await replaceSyncedAvailableModelsForConnection( + providerId, + connectionId, + discoveredModels + ); + } + + let syncedAliases = 0; + if (usesManagedAvailableModels(providerId)) { + 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), + { pruneMissing: mode === "sync" } + ); + syncedAliases = aliasSync.assignedAliases.length; + } + + const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds); + const importedModels = collectAddedImportedModels(previousModels, candidateImportedModels); + + return { + previousModels, + persistedModels, + importedModels, + discoveredModels, + syncedAvailableModels, + syncedAliases, + importedChanges, + }; +} diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 1e50f729eb..d14546ef4d 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -42,7 +42,7 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel .map((endpoint) => toNonEmptyString(endpoint)) .filter((endpoint): endpoint is string => Boolean(endpoint)) ) - ) + ).sort() : undefined; deduped.set(id, { diff --git a/src/shared/utils/modelCatalogSearch.ts b/src/shared/utils/modelCatalogSearch.ts index bf9a0bed11..c842ff6515 100644 --- a/src/shared/utils/modelCatalogSearch.ts +++ b/src/shared/utils/modelCatalogSearch.ts @@ -14,10 +14,17 @@ function normalizeText(value: string | null | undefined): string { export function normalizeModelCatalogSource(source?: string | null): ModelCatalogSource { const normalized = normalizeText(source); - if (normalized === "api-sync" || normalized === "synced") return "api-sync"; + if ( + normalized === "api-sync" || + normalized === "synced" || + normalized === "auto-sync" || + normalized === "imported" + ) { + return "api-sync"; + } if (normalized === "fallback") return "fallback"; if (normalized === "alias") return "alias"; - if (normalized === "custom" || normalized === "manual" || normalized === "imported") { + if (normalized === "custom" || normalized === "manual") { return "custom"; } diff --git a/tests/unit/model-catalog-source.test.ts b/tests/unit/model-catalog-source.test.ts new file mode 100644 index 0000000000..a56ce76a7d --- /dev/null +++ b/tests/unit/model-catalog-source.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +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"); +}); diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index c479bc37e5..1a2b4b085b 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -352,7 +352,7 @@ test("model sync route writes synced available models for Gemini connections", a { id: "gemini-custom-preview", name: "Gemini Custom Preview", - source: "auto-sync", + source: "api-sync", apiFormat: "chat-completions", supportedEndpoints: ["chat", "embeddings"], inputTokenLimit: 32768, @@ -428,6 +428,192 @@ test("model sync route writes synced available models for non-Gemini providers t ]); }); +test("model sync route import mode merges discovered models without deleting manual models", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "OpenRouter Import", + apiKey: "test-key", + }); + + await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual"); + await localDb.setModelAlias("manual-only", "openrouter/manual-only"); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return Response.json({ + models: [{ id: "router-v4", name: "Router V4" }], + }); + }; + + const response = await modelSyncRoute.POST( + 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 aliases = await localDb.getModelAliases(); + + assert.equal(response.status, 200); + assert.equal(body.mode, "merge"); + assert.equal(body.importedCount, 1); + 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.models.map((model) => ({ id: model.id, source: model.source })), + [ + { id: "manual-only", source: "manual" }, + { id: "router-v4", source: "api-sync" }, + ] + ); + assert.deepEqual( + body.importedModels.map((model) => ({ id: model.id, source: model.source })), + [{ id: "router-v4", source: "api-sync" }] + ); + assert.equal(aliases["manual-only"], "openrouter/manual-only"); + assert.equal(aliases["router-v4"], "openrouter/router-v4"); +}); + +test("model sync route import mode ignores supported endpoint ordering changes", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "OpenRouter Import Stable", + apiKey: "test-key", + }); + + await modelsDb.replaceCustomModels("openrouter", [ + { + id: "router-v4", + name: "Router V4", + source: "api-sync", + apiFormat: "chat-completions", + supportedEndpoints: ["chat", "embeddings"], + }, + ]); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return Response.json({ + models: [ + { + id: "router-v4", + name: "Router V4", + supportedEndpoints: ["embeddings", "chat"], + }, + ], + }); + }; + + const response = await modelSyncRoute.POST( + 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 }); + + assert.equal(response.status, 200); + assert.equal(body.importedCount, 0); + assert.equal(body.updatedCount, 0); + assert.deepEqual(body.importedChanges, { added: 0, updated: 0, unchanged: 1, total: 0 }); + assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); + assert.equal(body.logged, false); + assert.deepEqual(body.importedModels, []); + assert.deepEqual( + body.models.map((model) => ({ + id: model.id, + supportedEndpoints: model.supportedEndpoints, + })), + [{ id: "router-v4", supportedEndpoints: ["chat", "embeddings"] }] + ); + assert.equal(logs.length, 0); +}); + +test("model sync route import mode reports updates without counting them as new imports", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "OpenRouter Import Update", + apiKey: "test-key", + }); + + await modelsDb.replaceCustomModels("openrouter", [ + { + id: "router-v4", + name: "Router V4", + source: "api-sync", + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + }, + ]); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return Response.json({ + models: [ + { + id: "router-v4", + name: "Router V4 Updated", + supportedEndpoints: ["chat", "embeddings"], + }, + ], + }); + }; + + const response = await modelSyncRoute.POST( + 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 }); + + assert.equal(response.status, 200); + assert.equal(body.importedCount, 0); + assert.equal(body.updatedCount, 1); + assert.deepEqual(body.importedChanges, { added: 0, updated: 1, unchanged: 0, total: 1 }); + assert.deepEqual(body.importedModels, []); + assert.deepEqual( + body.models.map((model) => ({ + id: model.id, + name: model.name, + supportedEndpoints: model.supportedEndpoints, + })), + [ + { + id: "router-v4", + name: "Router V4 Updated", + supportedEndpoints: ["chat", "embeddings"], + }, + ] + ); + assert.equal(body.logged, true); + assert.equal(logs.length, 1); +}); + test("model sync route records added, removed, and updated model diffs with fallback identifiers", async () => { await resetStorage(); @@ -565,7 +751,8 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo assert.equal(response.status, 200); assert.equal(body.provider, "openrouter"); - assert.equal(body.syncedModels, 2); + assert.equal(body.syncedModels, 3); + assert.equal(body.availableModelsCount, 3); assert.equal(body.syncedAliases, 2); assert.equal(body.logged, true); assert.deepEqual(body.modelChanges, { added: 2, removed: 0, updated: 0, total: 2 }); @@ -584,6 +771,51 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo assert.equal(logs[0].account, "External Sync"); }); +test("model sync route reports synced managed models separately from preserved manual models", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "Mixed Sync", + apiKey: "test-key", + }); + + await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual"); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return Response.json({ + models: [{ id: "router-v4", name: "Router V4" }], + }); + }; + + 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; + + assert.equal(response.status, 200); + assert.equal(body.syncedModels, 1); + assert.equal(body.availableModelsCount, 2); + assert.equal(body.importedCount, 1); + assert.equal(body.updatedCount, 0); + assert.deepEqual( + body.models.map((model) => ({ id: model.id, source: model.source })), + [ + { id: "manual-only", source: "manual" }, + { id: "router-v4", source: "api-sync" }, + ] + ); +}); + test("model sync route uses provider-node prefixes when syncing compatible-provider aliases", async () => { await resetStorage(); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 6cb38c7bc7..2eb15c7303 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -222,6 +222,30 @@ test("provider models route returns the local catalog for built-in image provide assert.deepEqual(body.models, [{ id: "topaz-enhance", name: "topaz-enhance" }]); }); +test("provider models route prefers the remote OpenRouter /models API over static image models", async () => { + const connection = await seedConnection("openrouter", { + apiKey: "openrouter-key", + }); + const seenUrls = []; + + globalThis.fetch = async (url, init = {}) => { + seenUrls.push(String(url)); + assert.equal(init.method, "GET"); + assert.equal(init.headers.Authorization, "Bearer openrouter-key"); + return Response.json({ + data: [{ id: "openai/gpt-4.1", name: "GPT-4.1 via OpenRouter" }], + }); + }; + + const response = await callRoute(connection.id, "?refresh=true"); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.source, "api"); + assert.deepEqual(seenUrls, ["https://openrouter.ai/api/v1/models"]); + assert.deepEqual(body.models, [{ id: "openai/gpt-4.1", name: "GPT-4.1 via OpenRouter" }]); +}); + test("provider models route returns the local catalog for new built-in chat-openai-compat providers", async () => { const connection = await seedConnection("deepinfra", { apiKey: "deepinfra-key",