Fix OpenRouter remote discovery and unify managed model sync (#1521)

Integrated into release/v3.7.0
This commit is contained in:
Randi
2026-04-23 00:14:59 -04:00
committed by GitHub
parent 331e1b7d61
commit fff025ca0f
10 changed files with 626 additions and 159 deletions

View File

@@ -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"));

View File

@@ -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,

View File

@@ -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

View File

@@ -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[] = [];

View 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,
};
}

View File

@@ -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, {

View File

@@ -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";
}

View 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");
});

View File

@@ -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();

View File

@@ -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",