From b6622b314a96674ca23c6f7fbf0d488e710e5869 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Wed, 22 Apr 2026 23:13:10 -0400 Subject: [PATCH 1/3] Unify managed model sync and fix OpenRouter discovery --- .../dashboard/providers/[id]/page.tsx | 112 +++----- src/app/api/providers/[id]/models/route.ts | 16 +- .../api/providers/[id]/sync-models/route.ts | 88 +++--- .../providerModels/managedAvailableModels.ts | 20 +- src/lib/providerModels/managedModelImport.ts | 254 ++++++++++++++++++ src/shared/utils/modelCatalogSearch.ts | 11 +- tests/unit/model-catalog-source.test.ts | 13 + tests/unit/model-sync-route.test.ts | 55 +++- tests/unit/provider-models-route.test.ts | 24 ++ 9 files changed, 444 insertions(+), 149 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 13e7c1087f0c..c53673e59780 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; @@ -2004,10 +2001,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 +2014,51 @@ export default function ProviderDetailPage() { }); try { - const data = await fetchModels(); - const models = data.models || []; - if (models.length === 0) { - setImportProgress((prev) => ({ - ...prev, - phase: "done", - status: t("noModelsFound"), - logs: [t("noModelsReturnedFromEndpoint")], - })); - return; + 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")); } - 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; + const importedModels = Array.isArray(data.importedModels) ? data.importedModels : []; + const importedCount = + typeof data.importedCount === "number" ? data.importedCount : importedModels.length; + if (importedModels.length === 0) { setImportProgress((prev) => ({ ...prev, - current: i + 1, - status: t("importingModelsProgress", { current: i + 1, total: models.length }), - logs: [...prev.logs, t("importingModelById", { modelId })], + phase: "done", + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, })); - - const added = await processModel(model); - if (added) importedCount += 1; + return; } 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"), @@ -4493,49 +4485,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 e13810da3f6b..12ba06e398b1 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 808cbf8ace51..9ec3a8e9a275 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,22 +1,16 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; +import { getCustomModels } from "@/lib/db/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 +26,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( @@ -129,6 +127,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 +193,22 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: } const fetchedModels = modelsData.models || []; - - // 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); + const { persistedModels, importedModels, discoveredModels, syncedAliases, importedChanges } = + await importManagedModels({ + providerId: logProvider, + connectionId: id, + fetchedModels, + mode, + }); - 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 = + persistedModels.length > 0 + ? persistedModels.length + : importedModels.length > 0 + ? importedModels.length + : discoveredModels.length; if (modelChanges.total > 0) { await saveCallLog({ @@ -247,11 +222,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: duration: Date.now() - start, requestType: "model-sync", responseBody: { - syncedModels: models.length, + syncedModels: syncedModelsCount, syncedAliases, provider: logProvider, channel: channelLabel, modelChanges, + mode, }, }); } @@ -259,11 +235,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ ok: true, provider: logProvider, - syncedModels: replaced.length, + mode, + syncedModels: syncedModelsCount, syncedAliases, modelChanges, + importedCount: importedChanges.total, + 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 152f8b61279f..d862435a6ff7 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 000000000000..babba286d20f --- /dev/null +++ b/src/lib/providerModels/managedModelImport.ts @@ -0,0 +1,254 @@ +import { + getCustomModels, + replaceCustomModels, + replaceSyncedAvailableModelsForConnection, + type SyncedAvailableModel, +} from "@/lib/db/models"; +import { + syncManagedAvailableModelAliases, + usesManagedAvailableModels, +} from "@/lib/providerModels/managedAvailableModels"; +import { getModelsByProviderId } from "@/shared/constants/models"; + +type JsonRecord = Record; + +export type ManagedModelImportMode = "merge" | "sync"; + +type ManagedImportedModel = { + id: string; + name: string; + source: "api-sync"; + apiFormat: "chat-completions"; + supportedEndpoints?: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; +}; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +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 normalizeSupportedEndpoints(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const endpoints = Array.from( + new Set( + value + .map((entry) => toNonEmptyString(entry)) + .filter((entry): entry is string => Boolean(entry)) + ) + ); + return endpoints.length > 0 ? endpoints : undefined; +} + +function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel[] { + const items = Array.isArray(models) ? models : []; + const deduped = new Map(); + + for (const item of items) { + const record = asRecord(item); + const id = + toNonEmptyString(record.id) || + toNonEmptyString(record.name) || + toNonEmptyString(record.model); + if (!id) continue; + + const name = + toNonEmptyString(record.name) || + toNonEmptyString(record.displayName) || + toNonEmptyString(record.model) || + id; + const supportedEndpoints = normalizeSupportedEndpoints(record.supportedEndpoints); + + deduped.set(id, { + id, + name, + source: "api-sync", + ...(supportedEndpoints ? { 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 } : {}), + }); + } + + return Array.from(deduped.values()); +} + +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, + }; +} + +export async function importManagedModels({ + providerId, + connectionId, + fetchedModels, + mode, +}: { + providerId: string; + connectionId: string; + fetchedModels: unknown; + mode: ManagedModelImportMode; +}) { + const previousModels = (await getCustomModels(providerId)) as JsonRecord[]; + const importedModels = normalizeImportedModels(providerId, fetchedModels); + const importedIds = new Set(importedModels.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 importedModels) { + 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)) + : importedModels.map((model) => model.id), + { pruneMissing: mode === "sync" } + ); + syncedAliases = aliasSync.assignedAliases.length; + } + + const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds); + + return { + previousModels, + persistedModels, + importedModels, + discoveredModels, + syncedAvailableModels, + syncedAliases, + importedChanges, + }; +} diff --git a/src/shared/utils/modelCatalogSearch.ts b/src/shared/utils/modelCatalogSearch.ts index bf9a0bed1108..c842ff6515dc 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 000000000000..a56ce76a7d54 --- /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 c479bc37e539..79e8c828f25e 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,59 @@ 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.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 records added, removed, and updated model diffs with fallback identifiers", async () => { await resetStorage(); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 6cb38c7bc7ab..2eb15c73035e 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", From d0a3b47ddf706f310dfa997d3a372de052691d84 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Wed, 22 Apr 2026 23:31:24 -0400 Subject: [PATCH 2/3] Stabilize managed import endpoint comparisons --- src/lib/providerModels/managedModelImport.ts | 2 +- tests/unit/model-sync-route.test.ts | 61 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index babba286d20f..eab7ede5daf6 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -50,7 +50,7 @@ function normalizeSupportedEndpoints(value: unknown): string[] | undefined { .map((entry) => toNonEmptyString(entry)) .filter((entry): entry is string => Boolean(entry)) ) - ); + ).sort(); return endpoints.length > 0 ? endpoints : undefined; } diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index 79e8c828f25e..bbac116df6ce 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -481,6 +481,67 @@ test("model sync route import mode merges discovered models without deleting man 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.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.models.map((model) => ({ + id: model.id, + supportedEndpoints: model.supportedEndpoints, + })), + [{ id: "router-v4", supportedEndpoints: ["chat", "embeddings"] }] + ); + assert.equal(logs.length, 0); +}); + test("model sync route records added, removed, and updated model diffs with fallback identifiers", async () => { await resetStorage(); From 17a02cc96af9efd089e2f13ed8552272ecde3830 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Wed, 22 Apr 2026 23:47:11 -0400 Subject: [PATCH 3/3] Clarify managed sync counts and import results --- .../dashboard/providers/[id]/page.tsx | 27 +++- .../api/providers/[id]/sync-models/route.ts | 50 +++++--- src/lib/providerModels/managedModelImport.ts | 76 +++-------- src/lib/providerModels/modelDiscovery.ts | 2 +- tests/unit/model-sync-route.test.ts | 120 +++++++++++++++++- 5 files changed, 193 insertions(+), 82 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c53673e59780..11370b1d4219 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1353,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}`); @@ -1366,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, })); @@ -2026,6 +2032,10 @@ export default function ProviderDetailPage() { 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) => ({ @@ -2042,6 +2052,11 @@ export default function ProviderDetailPage() { ], importedCount, })); + if (changedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } return; } @@ -2066,7 +2081,7 @@ export default function ProviderDetailPage() { importedCount, })); - if (importedCount > 0) { + if (changedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 9ec3a8e9a275..9844deeb4d2c 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; -import { getCustomModels } from "@/lib/db/models"; import { importManagedModels, type ManagedModelImportMode, @@ -51,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 : []; @@ -193,22 +198,32 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: } const fetchedModels = modelsData.models || []; - const previousModels = await getCustomModels(logProvider); - const { persistedModels, importedModels, discoveredModels, syncedAliases, importedChanges } = - await importManagedModels({ - providerId: logProvider, - connectionId: id, - fetchedModels, - mode, - }); + const { + previousModels, + persistedModels, + importedModels, + discoveredModels, + syncedAliases, + importedChanges, + } = await importManagedModels({ + providerId: logProvider, + connectionId: id, + fetchedModels, + mode, + }); const modelChanges = summarizeModelChanges(previousModels, persistedModels); const syncedModelsCount = - persistedModels.length > 0 - ? persistedModels.length - : importedModels.length > 0 - ? importedModels.length - : discoveredModels.length; + 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({ @@ -223,10 +238,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: requestType: "model-sync", responseBody: { syncedModels: syncedModelsCount, + availableModelsCount, syncedAliases, provider: logProvider, channel: channelLabel, modelChanges, + importedCount, + updatedCount, mode, }, }); @@ -237,9 +255,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: provider: logProvider, mode, syncedModels: syncedModelsCount, + availableModelsCount, syncedAliases, modelChanges, - importedCount: importedChanges.total, + importedCount, + updatedCount, importedChanges, logged: modelChanges.total > 0, models: persistedModels, diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index eab7ede5daf6..41a2555cecd2 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -8,13 +8,14 @@ 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"; -type ManagedImportedModel = { +export type ManagedImportedModel = { id: string; name: string; source: "api-sync"; @@ -26,10 +27,6 @@ type ManagedImportedModel = { supportsThinking?: boolean; }; -function asRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - function toNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -42,56 +39,6 @@ function normalizeManagedSource(source: unknown): string { return normalized || "manual"; } -function normalizeSupportedEndpoints(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const endpoints = Array.from( - new Set( - value - .map((entry) => toNonEmptyString(entry)) - .filter((entry): entry is string => Boolean(entry)) - ) - ).sort(); - return endpoints.length > 0 ? endpoints : undefined; -} - -function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel[] { - const items = Array.isArray(models) ? models : []; - const deduped = new Map(); - - for (const item of items) { - const record = asRecord(item); - const id = - toNonEmptyString(record.id) || - toNonEmptyString(record.name) || - toNonEmptyString(record.model); - if (!id) continue; - - const name = - toNonEmptyString(record.name) || - toNonEmptyString(record.displayName) || - toNonEmptyString(record.model) || - id; - const supportedEndpoints = normalizeSupportedEndpoints(record.supportedEndpoints); - - deduped.set(id, { - id, - name, - source: "api-sync", - ...(supportedEndpoints ? { 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 } : {}), - }); - } - - return Array.from(deduped.values()); -} - function normalizeImportedModels( providerId: string, fetchedModels: unknown @@ -168,6 +115,16 @@ function summarizeImportedChanges( }; } +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, @@ -180,8 +137,8 @@ export async function importManagedModels({ mode: ManagedModelImportMode; }) { const previousModels = (await getCustomModels(providerId)) as JsonRecord[]; - const importedModels = normalizeImportedModels(providerId, fetchedModels); - const importedIds = new Set(importedModels.map((model) => model.id)); + const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels); + const importedIds = new Set(candidateImportedModels.map((model) => model.id)); const nextModelsMap = new Map(); @@ -197,7 +154,7 @@ export async function importManagedModels({ } } - for (const model of importedModels) { + for (const model of candidateImportedModels) { nextModelsMap.set(model.id, model); } @@ -234,13 +191,14 @@ export async function importManagedModels({ ? persistedModels .map((model) => toNonEmptyString(model.id)) .filter((modelId): modelId is string => Boolean(modelId)) - : importedModels.map((model) => model.id), + : 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, diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 1e50f729eb56..d14546ef4de4 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/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index bbac116df6ce..1a2b4b085bfa 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -464,6 +464,7 @@ test("model sync route import mode merges discovered models without deleting man 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( @@ -529,9 +530,11 @@ test("model sync route import mode ignores supported endpoint ordering changes", 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, @@ -542,6 +545,75 @@ test("model sync route import mode ignores supported endpoint ordering changes", 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(); @@ -679,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 }); @@ -698,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();