mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Fix OpenRouter remote discovery and unify managed model sync (#1521)
Integrated into release/v3.7.0
This commit is contained in:
@@ -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<boolean>
|
||||
) => Promise<void>;
|
||||
onImportWithProgress: (connectionId: string) => Promise<void>;
|
||||
t: (key: string, values?: Record<string, unknown>) => 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<boolean>
|
||||
) => {
|
||||
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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,11 @@ async function getProviderDisplayPrefix(providerId: string): Promise<string> {
|
||||
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[] = [];
|
||||
|
||||
212
src/lib/providerModels/managedModelImport.ts
Normal file
212
src/lib/providerModels/managedModelImport.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string>
|
||||
) {
|
||||
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<string, JsonRecord>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
13
tests/unit/model-catalog-source.test.ts
Normal file
13
tests/unit/model-catalog-source.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user