Fix model sync import handling (#1755)

* Fix model sync import handling

* Align model import storage semantics

* Address model review feedback
This commit is contained in:
Randi
2026-04-29 07:53:20 -04:00
committed by GitHub
parent c20d0599d5
commit e6a0fd104d
17 changed files with 651 additions and 246 deletions

View File

@@ -460,7 +460,7 @@ interface CooldownTimerProps {
function getModelSourceBadgeClass(source?: string): string {
switch (normalizeModelCatalogSource(source)) {
case "api-sync":
case "imported":
return "border-sky-500/30 bg-sky-500/10 text-sky-300";
case "custom":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300";
@@ -1041,12 +1041,12 @@ export default function ProviderDetailPage() {
const isOAuth = providerSupportsOAuth && !providerSupportsPat;
const registryModels = getModelsByProviderId(providerId);
// Prefer synced API-discovered models when available, then merge built-ins
// and user-managed custom/imported models without duplicating IDs.
// and user-managed custom models without duplicating IDs.
const models = useMemo(() => {
if (providerId === "gemini") {
return syncedAvailableModels.map((model: any) => ({
...model,
source: model?.source === "api-sync" ? "api-sync" : "api-sync",
source: "imported",
}));
}
@@ -1061,7 +1061,7 @@ export default function ProviderDetailPage() {
.map((model: any) => ({
id: model.id,
name: model.name || model.id,
source: "api-sync",
source: "imported",
}));
const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]);
const customExtras = modelMeta.customModels
@@ -1069,7 +1069,7 @@ export default function ProviderDetailPage() {
.map((cm: any) => ({
id: cm.id,
name: cm.name || cm.id,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
return [...builtInModels, ...syncedExtras, ...customExtras];
}, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]);
@@ -2097,6 +2097,9 @@ export default function ProviderDetailPage() {
typeof data.importedChanges?.total === "number"
? data.importedChanges.total
: importedCount;
const totalChangedCount =
changedCount +
(typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0);
if (importedModels.length === 0) {
setImportProgress((prev) => ({
@@ -2113,7 +2116,7 @@ export default function ProviderDetailPage() {
],
importedCount,
}));
if (changedCount > 0) {
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
@@ -2142,7 +2145,7 @@ export default function ProviderDetailPage() {
importedCount,
}));
if (changedCount > 0) {
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getAllCustomModels, getPricing } from "@/lib/localDb";
import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb";
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
@@ -20,8 +20,9 @@ function asModelArray(value: unknown): Array<{ id?: string; name?: string }> {
* GET /api/pricing/models
* Returns the full model catalog merged from three sources:
* 1. providerRegistry (hardcoded)
* 2. customModels (DB — user-added or imported via /models)
* 3. pricing data (DB — models with pricing configured but not in sources 1/2)
* 2. syncedAvailableModels (DB — discovered/imported from provider /models)
* 3. customModels (DB — manually added models)
* 4. pricing data (DB — models with pricing configured but not in sources 1/2/3)
*/
export async function GET() {
try {
@@ -46,25 +47,14 @@ export async function GET() {
};
}
// ── 2. Custom models (DB) ───────────────────────────────────────
let customModelsMap: Record<string, unknown> = {};
try {
customModelsMap = asRecord(await getAllCustomModels());
} catch {
/* DB may not be ready */
}
for (const [providerId, rawModels] of Object.entries(customModelsMap)) {
const models = asModelArray(rawModels);
// Resolve alias — check if a registry entry maps this providerId
let alias = providerId;
const resolveAlias = (providerId: string) => {
for (const entry of Object.values(REGISTRY)) {
if (entry.id === providerId) {
alias = entry.alias || entry.id;
break;
}
if (entry.id === providerId) return entry.alias || entry.id;
}
return providerId;
};
const ensureCatalogProvider = (providerId: string, alias: string) => {
if (!catalog[alias]) {
catalog[alias] = {
id: providerId,
@@ -75,25 +65,52 @@ export async function GET() {
models: [],
};
}
return catalog[alias];
};
const appendDbModels = (providerId: string, rawModels: unknown) => {
const models = asModelArray(rawModels);
const alias = resolveAlias(providerId);
const providerCatalog = ensureCatalogProvider(providerId, alias);
const existingIds = new Set(providerCatalog.models.map((m) => m.id));
const existingIds = new Set(catalog[alias].models.map((m) => m.id));
for (const model of models) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId || existingIds.has(modelId)) {
continue;
}
if (!existingIds.has(modelId)) {
catalog[alias].models.push({
id: modelId,
name: typeof model.name === "string" && model.name.trim() ? model.name : modelId,
custom: true,
});
existingIds.add(modelId);
}
if (!modelId || existingIds.has(modelId)) continue;
providerCatalog.models.push({
id: modelId,
name: typeof model.name === "string" && model.name.trim() ? model.name : modelId,
custom: true,
});
existingIds.add(modelId);
}
};
// ── 2. Synced available models (DB) ─────────────────────────────
let syncedModelsMap: Record<string, unknown> = {};
try {
syncedModelsMap = asRecord(await getAllSyncedAvailableModels());
} catch {
/* DB may not be ready */
}
// ── 3. Pricing-only models (DB) ─────────────────────────────────
for (const [providerId, rawModels] of Object.entries(syncedModelsMap)) {
appendDbModels(providerId, rawModels);
}
// ── 3. Custom models (DB) ───────────────────────────────────────
let customModelsMap: Record<string, unknown> = {};
try {
customModelsMap = asRecord(await getAllCustomModels());
} catch {
/* DB may not be ready */
}
for (const [providerId, rawModels] of Object.entries(customModelsMap)) {
appendDbModels(providerId, rawModels);
}
// ── 4. Pricing-only models (DB) ─────────────────────────────────
let pricingData: Record<string, any> = {};
try {
pricingData = await getPricing();

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { getSyncedAvailableModelsForConnection } from "@/lib/db/models";
import {
importManagedModels,
type ManagedModelImportMode,
@@ -10,6 +11,7 @@ import {
buildModelSyncInternalHeaders,
isModelSyncInternalRequest,
} from "@/shared/services/modelSyncScheduler";
import { GET as getProviderModels } from "../models/route";
type JsonRecord = Record<string, unknown>;
@@ -28,8 +30,8 @@ function normalizeModelForComparison(model: unknown) {
const rawSource = toNonEmptyString(record.source)?.toLowerCase();
const source =
rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported"
? "api-sync"
: rawSource || "auto-sync";
? "imported"
: rawSource || "manual";
const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions";
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
@@ -56,6 +58,41 @@ function isManagedSyncedModel(model: unknown) {
return source === "api-sync" || source === "auto-sync" || source === "imported";
}
function getErrorMessageFromPayload(payload: JsonRecord): string | null {
const error = payload.error;
if (typeof error === "string" && error.trim().length > 0) {
return error.trim();
}
const errorRecord = asRecord(error);
return toNonEmptyString(errorRecord.message) || toNonEmptyString(payload.message);
}
async function readJsonResponse(response: Response): Promise<{
data: JsonRecord;
parseError: string | null;
}> {
const body = await response.text();
if (!body.trim()) {
return {
data: {},
parseError: "Empty response body from /models",
};
}
try {
return {
data: asRecord(JSON.parse(body)),
parseError: null,
};
} catch {
return {
data: {},
parseError: "Invalid JSON response from /models",
};
}
}
function summarizeModelChanges(previousModels: unknown, nextModels: unknown) {
const previousList = Array.isArray(previousModels) ? previousModels : [];
const nextList = Array.isArray(nextModels) ? nextModels : [];
@@ -117,13 +154,54 @@ function getModelSyncChannelLabel(connection: unknown) {
);
}
async function fetchProviderModelsForSync(request: Request, connectionId: string) {
// Construct a safe localhost URL from the incoming request's origin.
// The route only accepts authenticated or internal-scheduler requests,
// and the path is hardcoded — no user-controlled URL components reach fetch.
const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
const incomingUrl = new URL(request.url);
const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname)
? incomingUrl.origin
: `http://127.0.0.1:${process.env.PORT || "20128"}`;
const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models?refresh=true`;
const headers = {
cookie: request.headers.get("cookie") || "",
...buildModelSyncInternalHeaders(),
};
try {
return await fetch(new URL(modelsPath, safeOrigin).href, {
method: "GET",
cache: "no-store",
headers,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[ModelSync] Internal /models self-fetch failed for ${connectionId.slice(
0,
8
)}; falling back to in-process route: ${message}`
);
return getProviderModels(
new Request(new URL(modelsPath, "http://localhost").href, {
method: "GET",
headers,
}),
{ params: { id: connectionId } }
);
}
}
/**
* POST /api/providers/[id]/sync-models
*
* Fetches the model list from a provider's /models endpoint and replaces the
* full custom models list for that provider while refreshing the per-connection
* discovery cache. Successful syncs only write a call log when the fetched
* channel actually changes the stored model list.
* Fetches the model list from a provider's /models endpoint, stores discovered
* models in the per-connection available-model cache, and removes matching
* upstream-discovered rows from the provider's custom model list. Successful
* syncs only write a call log when the fetched channel or custom model cleanup
* changes stored model state.
*
* Used by:
* - modelSyncScheduler (auto-sync on interval)
@@ -153,30 +231,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
logProvider = toNonEmptyString(connection.provider) || "unknown";
channelLabel = getModelSyncChannelLabel(connection);
const previousSyncedAvailableModelsForConnection = await getSyncedAvailableModelsForConnection(
logProvider,
id
);
// Fetch models from the existing /api/providers/[id]/models endpoint.
// Construct a safe localhost URL from the incoming request's origin.
// The route only accepts authenticated or internal-scheduler requests,
// and the path is hardcoded — no user-controlled URL components reach fetch.
const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
const incomingUrl = new URL(request.url);
const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname)
? incomingUrl.origin
: `http://127.0.0.1:${process.env.PORT || "20128"}`;
const modelsPath = `/api/providers/${encodeURIComponent(id)}/models?refresh=true`;
const modelsRes = await fetch(new URL(modelsPath, safeOrigin).href, {
method: "GET",
cache: "no-store",
headers: {
cookie: request.headers.get("cookie") || "",
...buildModelSyncInternalHeaders(),
},
});
const modelsRes = await fetchProviderModelsForSync(request, id);
const duration = Date.now() - start;
const modelsData = await modelsRes.json();
const { data: modelsData, parseError } = await readJsonResponse(modelsRes);
const payloadError = getErrorMessageFromPayload(modelsData);
if (!modelsRes.ok) {
if (!modelsRes.ok || parseError) {
const responseStatus = modelsRes.ok ? 502 : modelsRes.status;
const logError = payloadError || parseError || `HTTP ${modelsRes.status}`;
const responseError = payloadError || parseError || "Failed to fetch models";
// Log the failed attempt
await saveCallLog({
method: "GET",
@@ -187,22 +256,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
sourceFormat: "-",
connectionId: id,
duration,
error: modelsData.error || `HTTP ${modelsRes.status}`,
error: logError,
requestType: "model-sync",
...(parseError
? {
responseBody: {
upstreamStatus: modelsRes.status,
parseError,
},
}
: {}),
});
return NextResponse.json(
{ error: modelsData.error || "Failed to fetch models" },
{ status: modelsRes.status }
{
error: responseError,
...(parseError ? { upstreamStatus: modelsRes.status } : {}),
},
{ status: responseStatus }
);
}
const fetchedModels = modelsData.models || [];
const {
previousModels,
previousSyncedAvailableModels,
persistedModels,
importedModels,
discoveredModels,
syncedAvailableModels,
syncedAliases,
importedChanges,
} = await importManagedModels({
@@ -210,22 +292,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
connectionId: id,
fetchedModels,
mode,
previousSyncedAvailableModels: previousSyncedAvailableModelsForConnection,
});
const modelChanges = summarizeModelChanges(previousModels, persistedModels);
const effectiveAvailableModels =
discoveredModels.length > 0 ? discoveredModels : syncedAvailableModels;
const modelChanges = summarizeModelChanges(
previousSyncedAvailableModels,
effectiveAvailableModels
);
const customModelChanges = summarizeModelChanges(previousModels, persistedModels);
const syncedModelsCount =
discoveredModels.length > 0
? discoveredModels.length
effectiveAvailableModels.length > 0
? effectiveAvailableModels.length
: persistedModels.filter((model) => isManagedSyncedModel(model)).length;
const availableModelsCount = new Set(
[...persistedModels, ...discoveredModels]
[...persistedModels, ...effectiveAvailableModels]
.map((model) => toNonEmptyString(asRecord(model).id))
.filter((modelId): modelId is string => Boolean(modelId))
).size;
const importedCount = importedChanges.added;
const updatedCount = importedChanges.updated;
const shouldLog = modelChanges.total > 0 || customModelChanges.total > 0;
if (modelChanges.total > 0) {
if (shouldLog) {
await saveCallLog({
method: "GET",
path: `/api/providers/${id}/models`,
@@ -243,6 +333,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
provider: logProvider,
channel: channelLabel,
modelChanges,
customModelChanges,
importedCount,
updatedCount,
mode,
@@ -258,10 +349,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
availableModelsCount,
syncedAliases,
modelChanges,
customModelChanges,
importedCount,
updatedCount,
importedChanges,
logged: modelChanges.total > 0,
logged: shouldLog,
models: persistedModels,
importedModels,
});

View File

@@ -1,5 +1,9 @@
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models";
import {
getAllCustomModels,
getAllSyncedAvailableModels,
getSyncedAvailableModels,
} from "@/lib/db/models";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import { getSyncedCapabilities } from "@/lib/modelsDevSync";
@@ -67,6 +71,46 @@ export async function GET() {
console.error("[v1beta/models] Error fetching synced Gemini models:", err);
}
const existingNames = new Set(models.map((model) => (model as any).name));
// Synced/imported models for non-Gemini providers
try {
const syncedModelsMap = await getAllSyncedAvailableModels();
for (const [providerId, syncedModels] of Object.entries(syncedModelsMap)) {
if (providerId === "gemini") continue;
if (!Array.isArray(syncedModels)) continue;
for (const m of syncedModels) {
if (!m || typeof m.id !== "string") continue;
const name = `models/${providerId}/${m.id}`;
if (existingNames.has(name)) continue;
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: m.id,
});
models.push({
name,
displayName: m.name || m.id,
...(typeof m.description === "string" ? { description: m.description } : {}),
supportedGenerationMethods: ["generateContent"],
inputTokenLimit:
typeof m.inputTokenLimit === "number"
? m.inputTokenLimit
: resolved.maxInputTokens || resolved.contextWindow || 128000,
outputTokenLimit:
typeof m.outputTokenLimit === "number"
? m.outputTokenLimit
: resolved.maxOutputTokens || 8192,
...(m.supportsThinking === true || resolved.supportsThinking === true
? { thinking: true }
: {}),
});
existingNames.add(name);
}
}
} catch {
// Synced models are optional — skip on error
}
// Custom models (use stored metadata from provider APIs)
try {
const customModelsMap = (await getAllCustomModels()) as Record<string, unknown>;
@@ -83,8 +127,10 @@ export async function GET() {
provider: providerId,
model: String(m.id),
});
const name = `models/${providerId}/${m.id}`;
if (existingNames.has(name)) continue;
models.push({
name: `models/${providerId}/${m.id}`,
name,
displayName: m.name || m.id,
...(typeof m.description === "string" ? { description: m.description } : {}),
supportedGenerationMethods: ["generateContent"],
@@ -100,6 +146,7 @@ export async function GET() {
? { thinking: true }
: {}),
});
existingNames.add(name);
}
}
} catch {

View File

@@ -21,7 +21,7 @@ import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry.
type JsonRecord = Record<string, unknown>;
type BuilderModelSource = "api-sync" | "system" | "custom" | "fallback";
type BuilderModelSource = "imported" | "system" | "custom" | "fallback";
type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error";
type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" };
@@ -159,7 +159,7 @@ function isChatCapable(supportedEndpoints: string[] | undefined): boolean {
function getSourcePriority(source: BuilderModelSource): number {
switch (source) {
case "api-sync":
case "imported":
return 0;
case "system":
return 1;
@@ -412,7 +412,7 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
addModelOption(modelMap, providerId, {
id: toStringOrNull(model.id),
name: toStringOrNull(model.name),
source: "api-sync",
source: "imported",
supportedEndpoints: toStringArray(model.supportedEndpoints),
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
@@ -440,8 +440,11 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
for (const model of customModels) {
if (model.isHidden === true) continue;
const source =
toStringOrNull(model.source) === "api-sync" ? "api-sync" : ("custom" as BuilderModelSource);
const source = ["api-sync", "auto-sync", "imported"].includes(
toStringOrNull(model.source)?.toLowerCase() || ""
)
? "imported"
: ("custom" as BuilderModelSource);
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: toStringOrNull(model.id),

View File

@@ -234,6 +234,10 @@ 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 getKeyValue(row: unknown): { key: string | null; value: string | null } {
const record = asRecord(row);
return {
@@ -378,7 +382,7 @@ export async function addCustomModel(
}
/**
* Replace the entire custom models list for a provider (used by auto-sync).
* Replace the entire custom models list for a provider.
* Preserves per-model compatibility overrides for models that still exist.
*/
export async function replaceCustomModels(
@@ -397,7 +401,7 @@ export async function replaceCustomModels(
{ allowEmpty = false }: { allowEmpty?: boolean } = {}
) {
// Guard: skip destructive clear when the caller hasn't explicitly opted in.
// This prevents auto-sync from wiping manually-imported models when the
// This prevents callers from wiping manually added models when the
// upstream /models endpoint fails, times out, or returns an empty list.
if (models.length === 0 && !allowEmpty) {
const existing = await getCustomModels(providerId);
@@ -520,7 +524,7 @@ export async function removeCustomModel(providerId: string, modelId: string) {
export interface SyncedAvailableModel {
id: string;
name: string;
source: "api-sync";
source: "imported";
supportedEndpoints?: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
@@ -528,6 +532,57 @@ export interface SyncedAvailableModel {
supportsThinking?: boolean;
}
type SyncedAvailableModelInput = Omit<SyncedAvailableModel, "source"> & {
source?: string;
};
function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null {
const record = asRecord(model);
const id =
toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model);
if (!id) return null;
const name =
toNonEmptyString(record.name) ||
toNonEmptyString(record.displayName) ||
toNonEmptyString(record.model) ||
id;
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
new Set(
record.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: undefined;
return {
id,
name,
source: "imported",
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(typeof record.inputTokenLimit === "number"
? { inputTokenLimit: record.inputTokenLimit }
: {}),
...(typeof record.outputTokenLimit === "number"
? { outputTokenLimit: record.outputTokenLimit }
: {}),
...(typeof record.description === "string" ? { description: record.description } : {}),
...(record.supportsThinking === true ? { supportsThinking: true } : {}),
};
}
function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] {
if (!Array.isArray(models)) return [];
const deduped = new Map<string, SyncedAvailableModel>();
for (const model of models) {
const normalized = normalizeSyncedAvailableModel(model);
if (normalized) deduped.set(normalized.id, normalized);
}
return Array.from(deduped.values());
}
/**
* Get synced available models for a specific provider connection.
*/
@@ -544,7 +599,7 @@ export async function getSyncedAvailableModelsForConnection(
if (!value) return [];
try {
const models = JSON.parse(value);
return Array.isArray(models) ? models : [];
return normalizeSyncedAvailableModels(models);
} catch {
return [];
}
@@ -566,7 +621,7 @@ export async function getSyncedAvailableModels(
for (const row of rows) {
const { key, value } = getKeyValue(row);
if (!key || value === null) continue;
const models: SyncedAvailableModel[] = JSON.parse(value);
const models = normalizeSyncedAvailableModels(JSON.parse(value));
for (const m of models) {
if (m.id) map.set(m.id, m);
}
@@ -591,7 +646,7 @@ export async function getAllSyncedAvailableModels(): Promise<
if (!key || value === null) continue;
const providerId = key.split(":")[0];
if (!byProvider.has(providerId)) byProvider.set(providerId, new Map());
const models: SyncedAvailableModel[] = JSON.parse(value);
const models = normalizeSyncedAvailableModels(JSON.parse(value));
const map = byProvider.get(providerId)!;
for (const m of models) {
if (m.id) map.set(m.id, m);
@@ -611,18 +666,19 @@ export async function getAllSyncedAvailableModels(): Promise<
export async function replaceSyncedAvailableModelsForConnection(
providerId: string,
connectionId: string,
models: SyncedAvailableModel[]
models: SyncedAvailableModelInput[]
): Promise<SyncedAvailableModel[]> {
const db = getDbInstance();
const key = `${providerId}:${connectionId}`;
if (models.length === 0) {
const normalizedModels = normalizeSyncedAvailableModels(models);
if (normalizedModels.length === 0) {
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(
key
);
} else {
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)"
).run(key, JSON.stringify(models));
).run(key, JSON.stringify(normalizedModels));
}
backupDbFile("pre-write");
// Return the full unioned list for the provider

View File

@@ -1,7 +1,10 @@
import {
getCustomModels,
getSyncedAvailableModelsForConnection,
mergeModelCompatOverride,
replaceCustomModels,
replaceSyncedAvailableModelsForConnection,
type ModelCompatPatch,
type SyncedAvailableModel,
} from "@/lib/db/models";
import {
@@ -9,7 +12,6 @@ import {
usesManagedAvailableModels,
} from "@/lib/providerModels/managedAvailableModels";
import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery";
import { getModelsByProviderId } from "@/shared/constants/models";
type JsonRecord = Record<string, unknown>;
@@ -18,7 +20,7 @@ export type ManagedModelImportMode = "merge" | "sync";
export type ManagedImportedModel = {
id: string;
name: string;
source: "api-sync";
source: "imported";
apiFormat: "chat-completions";
supportedEndpoints?: string[];
inputTokenLimit?: number;
@@ -34,42 +36,39 @@ function toNonEmptyString(value: unknown): string | null {
function normalizeManagedSource(source: unknown): string {
const normalized = toNonEmptyString(source)?.toLowerCase();
if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") {
return "api-sync";
return "imported";
}
return normalized || "manual";
}
function normalizeImportedModels(
providerId: string,
fetchedModels: unknown
): ManagedImportedModel[] {
function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] {
const discovered = normalizeDiscoveredModels(fetchedModels);
const registryIds = new Set(getModelsByProviderId(providerId).map((model: any) => model.id));
return discovered
.filter((model) => !registryIds.has(model.id))
.map((model) => ({
id: model.id,
name: model.name || model.id,
source: "api-sync",
apiFormat: "chat-completions",
...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0
? { supportedEndpoints: model.supportedEndpoints }
: {}),
...(typeof model.inputTokenLimit === "number"
? { inputTokenLimit: model.inputTokenLimit }
: {}),
...(typeof model.outputTokenLimit === "number"
? { outputTokenLimit: model.outputTokenLimit }
: {}),
...(typeof model.description === "string" ? { description: model.description } : {}),
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
}));
return discovered.map((model) => ({
id: model.id,
name: model.name || model.id,
source: "imported",
apiFormat: "chat-completions",
...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0
? { supportedEndpoints: model.supportedEndpoints }
: {}),
...(typeof model.inputTokenLimit === "number"
? { inputTokenLimit: model.inputTokenLimit }
: {}),
...(typeof model.outputTokenLimit === "number"
? { outputTokenLimit: model.outputTokenLimit }
: {}),
...(typeof model.description === "string" ? { description: model.description } : {}),
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
}));
}
function isManagedDiscoveredSource(source: unknown): boolean {
const normalized = toNonEmptyString(source)?.toLowerCase();
return normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported";
function isImportedSource(source: unknown): boolean {
return normalizeManagedSource(source) === "imported";
}
function getModelId(model: JsonRecord): string | null {
return toNonEmptyString(model.id);
}
function summarizeImportedChanges(
@@ -86,9 +85,30 @@ function summarizeImportedChanges(
const toComparable = (model: JsonRecord | undefined) => {
if (!model) return null;
const id = toNonEmptyString(model.id) || "";
const supportedEndpoints = Array.isArray(model.supportedEndpoints)
? Array.from(
new Set(
model.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: ["chat"];
return {
...model,
id,
name: toNonEmptyString(model.name) || id,
source: normalizeManagedSource(model.source),
apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions",
supportedEndpoints,
...(typeof model.inputTokenLimit === "number"
? { inputTokenLimit: model.inputTokenLimit }
: {}),
...(typeof model.outputTokenLimit === "number"
? { outputTokenLimit: model.outputTokenLimit }
: {}),
...(typeof model.description === "string" ? { description: model.description } : {}),
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
};
};
@@ -125,37 +145,71 @@ function collectAddedImportedModels(
return importedModels.filter((model) => !previousIds.has(model.id));
}
function getCompatPatchFromCustomModel(model: JsonRecord): ModelCompatPatch | null {
const patch: ModelCompatPatch = {};
if (typeof model.normalizeToolCallId === "boolean") {
patch.normalizeToolCallId = model.normalizeToolCallId;
}
if (typeof model.preserveOpenAIDeveloperRole === "boolean") {
patch.preserveOpenAIDeveloperRole = model.preserveOpenAIDeveloperRole;
}
if (typeof model.isHidden === "boolean") {
patch.isHidden = model.isHidden;
}
if (model.compatByProtocol && typeof model.compatByProtocol === "object") {
patch.compatByProtocol = model.compatByProtocol as ModelCompatPatch["compatByProtocol"];
}
if (model.upstreamHeaders && typeof model.upstreamHeaders === "object") {
patch.upstreamHeaders = model.upstreamHeaders as Record<string, string>;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function preserveRemovedCustomModelCompat(providerId: string, removedModels: JsonRecord[]) {
for (const model of removedModels) {
const modelId = getModelId(model);
if (!modelId) continue;
const patch = getCompatPatchFromCustomModel(model);
if (!patch) continue;
mergeModelCompatOverride(providerId, modelId, patch);
}
}
export async function importManagedModels({
providerId,
connectionId,
fetchedModels,
mode,
previousSyncedAvailableModels: previousSyncedAvailableModelsInput,
}: {
providerId: string;
connectionId: string;
fetchedModels: unknown;
mode: ManagedModelImportMode;
previousSyncedAvailableModels?: SyncedAvailableModel[];
}) {
const previousModels = (await getCustomModels(providerId)) as JsonRecord[];
const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels);
const previousSyncedAvailableModels =
previousSyncedAvailableModelsInput ??
(await getSyncedAvailableModelsForConnection(providerId, connectionId));
const discoveredModels = normalizeDiscoveredModels(fetchedModels);
const candidateImportedModels = normalizeImportedModels(fetchedModels);
const importedIds = new Set(candidateImportedModels.map((model) => model.id));
const discoveredIds = new Set(discoveredModels.map((model) => model.id));
const nextModelsMap = new Map<string, JsonRecord>();
const removedCustomModels: JsonRecord[] = [];
if (mode === "merge") {
for (const model of previousModels) {
if (model?.id) nextModelsMap.set(String(model.id), model);
for (const model of previousModels) {
const modelId = getModelId(model);
if (!modelId) continue;
if (isImportedSource(model.source) || discoveredIds.has(modelId)) {
removedCustomModels.push(model);
continue;
}
} else {
for (const model of previousModels) {
if (!model?.id) continue;
if (isManagedDiscoveredSource(model.source)) continue;
nextModelsMap.set(String(model.id), model);
}
}
for (const model of candidateImportedModels) {
nextModelsMap.set(model.id, model);
nextModelsMap.set(modelId, model);
}
const persistedModels = (await replaceCustomModels(
@@ -170,11 +224,12 @@ export async function importManagedModels({
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
}>
}>,
{ allowEmpty: true }
)) as JsonRecord[];
preserveRemovedCustomModelCompat(providerId, removedCustomModels);
const discoveredModels = normalizeDiscoveredModels(fetchedModels);
let syncedAvailableModels: SyncedAvailableModel[] = [];
let syncedAvailableModels: SyncedAvailableModel[] = previousSyncedAvailableModels;
if (discoveredModels.length > 0) {
syncedAvailableModels = await replaceSyncedAvailableModelsForConnection(
providerId,
@@ -184,24 +239,28 @@ export async function importManagedModels({
}
let syncedAliases = 0;
if (usesManagedAvailableModels(providerId)) {
if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) {
const aliasSync = await syncManagedAvailableModelAliases(
providerId,
mode === "sync"
? persistedModels
.map((model) => toNonEmptyString(model.id))
.filter((modelId): modelId is string => Boolean(modelId))
: candidateImportedModels.map((model) => model.id),
discoveredModels.map((model) => model.id),
{ pruneMissing: mode === "sync" }
);
syncedAliases = aliasSync.assignedAliases.length;
}
const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds);
const importedModels = collectAddedImportedModels(previousModels, candidateImportedModels);
const importedChanges = summarizeImportedChanges(
previousSyncedAvailableModels as JsonRecord[],
discoveredModels as JsonRecord[],
importedIds
);
const importedModels = collectAddedImportedModels(
previousSyncedAvailableModels as JsonRecord[],
candidateImportedModels
);
return {
previousModels,
previousSyncedAvailableModels,
persistedModels,
importedModels,
discoveredModels,

View File

@@ -48,7 +48,7 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel
deduped.set(id, {
id,
name,
source: "api-sync",
source: "imported",
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(typeof record.inputTokenLimit === "number"
? { inputTokenLimit: record.inputTokenLimit }

View File

@@ -9,6 +9,7 @@ import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableMod
import {
getModelCatalogSourceLabel,
matchesModelCatalogQuery,
normalizeModelCatalogSource,
} from "@/shared/utils/modelCatalogSearch";
import {
OAUTH_PROVIDERS,
@@ -144,7 +145,7 @@ export default function ModelSelectModal({
name: cm.name || cm.id,
value: `${alias}/${cm.id}`,
isCustom: true,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
const allModels = [...aliasModels, ...customEntries];
@@ -198,7 +199,7 @@ export default function ModelSelectModal({
name: cm.name || cm.id,
value: `${nodePrefix}/${cm.id}`,
isCustom: true,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
const allModels = [...nodeModels, ...fallbackEntries, ...customEntries];
@@ -231,7 +232,7 @@ export default function ModelSelectModal({
name: cm.name || cm.id,
value: `${alias}/${cm.id}`,
isCustom: true,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
const allModels = [...systemEntries, ...customEntries];

View File

@@ -1,4 +1,4 @@
export type ModelCatalogSource = "system" | "custom" | "api-sync" | "fallback" | "alias";
export type ModelCatalogSource = "system" | "custom" | "imported" | "fallback" | "alias";
type ModelCatalogTarget = {
modelId?: string | null;
@@ -20,7 +20,7 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo
normalized === "auto-sync" ||
normalized === "imported"
) {
return "api-sync";
return "imported";
}
if (normalized === "fallback") return "fallback";
if (normalized === "alias") return "alias";
@@ -33,8 +33,8 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo
export function getModelCatalogSourceLabel(source?: string | null): string {
switch (normalizeModelCatalogSource(source)) {
case "api-sync":
return "Synced";
case "imported":
return "Imported";
case "custom":
return "Custom";
case "fallback":
@@ -49,7 +49,7 @@ export function getModelCatalogSourceLabel(source?: string | null): string {
function getModelCatalogSourceSearchText(source?: string | null): string {
switch (normalizeModelCatalogSource(source)) {
case "api-sync":
case "imported":
return "synced api imported discovered";
case "custom":
return "custom manual imported";

View File

@@ -115,7 +115,7 @@ test("replaceCustomModels preserves compat fields and respects the empty-list gu
{
id: "gpt-4.1",
name: "GPT-4.1 Refreshed",
source: "api-sync",
source: "imported",
supportsThinking: true,
},
]);
@@ -147,12 +147,12 @@ test("removing a custom model also removes its compat override", async () => {
test("synced available models are unioned across connections and cleaned per connection", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-a", [
{ id: "gpt-4.1", name: "GPT-4.1", source: "api-sync" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" },
{ id: "gpt-4.1", name: "GPT-4.1", source: "imported" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" },
]);
const union = await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-b", [
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" },
{ id: "o3-mini", name: "o3-mini", source: "api-sync" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" },
{ id: "o3-mini", name: "o3-mini", source: "imported" },
]);
const remaining = await modelsDb.deleteSyncedAvailableModelsForConnection("openai", "conn-a");
const allProviders = await modelsDb.getAllSyncedAvailableModels();

View File

@@ -9,8 +9,8 @@ import {
test("model catalog source normalization groups manual and synced rows separately", () => {
assert.equal(normalizeModelCatalogSource("manual"), "custom");
assert.equal(normalizeModelCatalogSource("imported"), "api-sync");
assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("imported"), "imported");
assert.equal(normalizeModelCatalogSource("api-sync"), "imported");
assert.equal(normalizeModelCatalogSource("fallback"), "fallback");
assert.equal(normalizeModelCatalogSource("alias"), "alias");
assert.equal(normalizeModelCatalogSource(undefined), "system");
@@ -19,7 +19,7 @@ test("model catalog source normalization groups manual and synced rows separatel
test("model catalog source labels stay user-facing", () => {
assert.equal(getModelCatalogSourceLabel("system"), "Built-in");
assert.equal(getModelCatalogSourceLabel("custom"), "Custom");
assert.equal(getModelCatalogSourceLabel("api-sync"), "Synced");
assert.equal(getModelCatalogSourceLabel("imported"), "Imported");
assert.equal(getModelCatalogSourceLabel("fallback"), "Fallback");
assert.equal(getModelCatalogSourceLabel("alias"), "Alias");
});
@@ -29,7 +29,7 @@ test("model catalog query matches id, display name, alias and source label", ()
modelId: "qwen/qwen3-coder-480b-a35b-instruct",
modelName: "Qwen3 Coder 480B",
alias: "best-qwen",
source: "api-sync",
source: "imported",
};
assert.equal(matchesModelCatalogQuery("", target), true);

View File

@@ -5,9 +5,9 @@ const { getModelCatalogSourceLabel, normalizeModelCatalogSource } =
await import("../../src/shared/utils/modelCatalogSearch.ts");
test("model catalog source normalizes synced import variants consistently", () => {
assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("auto-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("imported"), "api-sync");
assert.equal(getModelCatalogSourceLabel("auto-sync"), "Synced");
assert.equal(getModelCatalogSourceLabel("imported"), "Synced");
assert.equal(normalizeModelCatalogSource("api-sync"), "imported");
assert.equal(normalizeModelCatalogSource("imported"), "imported");
assert.equal(normalizeModelCatalogSource("auto-sync"), "imported");
assert.equal(getModelCatalogSourceLabel("auto-sync"), "Imported");
assert.equal(getModelCatalogSourceLabel("imported"), "Imported");
});

View File

@@ -53,11 +53,11 @@ test("model sync route skips success log when fetched models do not change store
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "custom-model-1",
name: "Custom Model 1",
source: "auto-sync",
source: "imported",
},
]);
@@ -85,6 +85,7 @@ test("model sync route skips success log when fetched models do not change store
const body = (await response.json()) as any;
assert.equal(body.logged, false);
assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 });
assert.deepEqual(body.models, []);
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
assert.equal(logs.length, 0);
@@ -250,6 +251,45 @@ test("model sync route falls back to the upstream HTTP status when the models pa
assert.equal(logs[0].error, "HTTP 429");
});
test("model sync route reports invalid JSON /models responses without losing upstream status", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "Invalid JSON Sync",
apiKey: "test-key",
});
globalThis.fetch = async (url) => {
assert.equal(
String(url),
`http://localhost/api/providers/${connection.id}/models?refresh=true`
);
return new Response("<html>bad gateway</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
};
const response = await modelSyncRoute.POST(
new Request(`http://localhost/api/providers/${connection.id}/sync-models`, {
method: "POST",
headers: scheduler.buildModelSyncInternalHeaders(),
}),
{ params: { id: connection.id } }
);
const body = (await response.json()) as any;
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
assert.equal(response.status, 502);
assert.equal(body.error, "Invalid JSON response from /models");
assert.equal(body.upstreamStatus, 200);
assert.equal(logs.length, 1);
assert.equal(logs[0].status, 200);
assert.equal(logs[0].error, "Invalid JSON response from /models");
});
test("model sync route preserves previously synced models when the upstream omits the models list", async () => {
await resetStorage();
@@ -260,11 +300,11 @@ test("model sync route preserves previously synced models when the upstream omit
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "persisted-model",
name: "Persisted Model",
source: "auto-sync",
source: "imported",
},
]);
@@ -290,13 +330,12 @@ test("model sync route preserves previously synced models when the upstream omit
assert.equal(body.syncedModels, 1);
assert.equal(body.logged, false);
assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 });
assert.deepEqual(body.models, [
assert.deepEqual(body.models, []);
assert.deepEqual(await modelsDb.getSyncedAvailableModels("openrouter"), [
{
id: "persisted-model",
name: "Persisted Model",
source: "auto-sync",
apiFormat: "chat-completions",
supportedEndpoints: ["chat"],
source: "imported",
},
]);
assert.equal(logs.length, 0);
@@ -348,24 +387,12 @@ test("model sync route writes synced available models for Gemini connections", a
assert.equal(body.syncedModels, 1);
assert.equal(body.logged, true);
assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 });
assert.deepEqual(body.models, [
{
id: "gemini-custom-preview",
name: "Gemini Custom Preview",
source: "api-sync",
apiFormat: "chat-completions",
supportedEndpoints: ["chat", "embeddings"],
inputTokenLimit: 32768,
outputTokenLimit: 8192,
description: "Custom Gemini preview model",
supportsThinking: true,
},
]);
assert.deepEqual(body.models, []);
assert.deepEqual(synced, [
{
id: "gemini-custom-preview",
name: "Gemini Custom Preview",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat", "embeddings"],
inputTokenLimit: 32768,
outputTokenLimit: 8192,
@@ -421,7 +448,7 @@ test("model sync route writes synced available models for non-Gemini providers t
{
id: "glm-5.1",
name: "GLM 5.1",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat"],
inputTokenLimit: 262144,
},
@@ -439,6 +466,7 @@ test("model sync route import mode merges discovered models without deleting man
});
await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual");
await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual");
await localDb.setModelAlias("manual-only", "openrouter/manual-only");
globalThis.fetch = async (url) => {
@@ -467,16 +495,21 @@ test("model sync route import mode merges discovered models without deleting man
assert.equal(body.updatedCount, 0);
assert.equal(body.syncedAliases, 1);
assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 });
assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 });
assert.deepEqual(
body.models.map((model) => ({ id: model.id, source: model.source })),
[
{ id: "manual-only", source: "manual" },
{ id: "router-v4", source: "api-sync" },
]
[{ id: "manual-only", source: "manual" }]
);
assert.deepEqual(
body.importedModels.map((model) => ({ id: model.id, source: model.source })),
[{ id: "router-v4", source: "api-sync" }]
[{ id: "router-v4", source: "imported" }]
);
assert.deepEqual(
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
source: model.source,
})),
[{ id: "router-v4", source: "imported" }]
);
assert.equal(aliases["manual-only"], "openrouter/manual-only");
assert.equal(aliases["router-v4"], "openrouter/router-v4");
@@ -492,12 +525,11 @@ test("model sync route import mode ignores supported endpoint ordering changes",
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "router-v4",
name: "Router V4",
source: "api-sync",
apiFormat: "chat-completions",
source: "imported",
supportedEndpoints: ["chat", "embeddings"],
},
]);
@@ -536,12 +568,13 @@ test("model sync route import mode ignores supported endpoint ordering changes",
assert.equal(body.logged, false);
assert.deepEqual(body.importedModels, []);
assert.deepEqual(
body.models.map((model) => ({
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
supportedEndpoints: model.supportedEndpoints,
})),
[{ id: "router-v4", supportedEndpoints: ["chat", "embeddings"] }]
);
assert.deepEqual(body.models, []);
assert.equal(logs.length, 0);
});
@@ -555,12 +588,11 @@ test("model sync route import mode reports updates without counting them as new
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "router-v4",
name: "Router V4",
source: "api-sync",
apiFormat: "chat-completions",
source: "imported",
supportedEndpoints: ["chat"],
},
]);
@@ -597,7 +629,7 @@ test("model sync route import mode reports updates without counting them as new
assert.deepEqual(body.importedChanges, { added: 0, updated: 1, unchanged: 0, total: 1 });
assert.deepEqual(body.importedModels, []);
assert.deepEqual(
body.models.map((model) => ({
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
name: model.name,
supportedEndpoints: model.supportedEndpoints,
@@ -610,6 +642,7 @@ test("model sync route import mode reports updates without counting them as new
},
]
);
assert.deepEqual(body.models, []);
assert.equal(body.logged, true);
assert.equal(logs.length, 1);
});
@@ -624,16 +657,16 @@ test("model sync route records added, removed, and updated model diffs with fall
accessToken: "sync-token",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "persisted-model",
name: "Persisted Model",
source: "auto-sync",
source: "imported",
},
{
id: "removed-model",
name: "Removed Model",
source: "auto-sync",
source: "imported",
},
]);
@@ -673,7 +706,7 @@ test("model sync route records added, removed, and updated model diffs with fall
assert.equal(body.logged, true);
assert.deepEqual(body.modelChanges, { added: 1, removed: 1, updated: 1, total: 3 });
assert.deepEqual(
body.models.map((model) => ({
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
name: model.name,
supportedEndpoints: model.supportedEndpoints,
@@ -689,7 +722,7 @@ test("model sync route records added, removed, and updated model diffs with fall
{
id: "fallback-model",
name: "Fallback Model",
supportedEndpoints: ["chat"],
supportedEndpoints: undefined,
description: "Fallback from model field",
},
]
@@ -753,17 +786,12 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo
assert.equal(body.provider, "openrouter");
assert.equal(body.syncedModels, 3);
assert.equal(body.availableModelsCount, 3);
assert.equal(body.syncedAliases, 2);
assert.equal(body.syncedAliases, 3);
assert.equal(body.logged, true);
assert.deepEqual(body.modelChanges, { added: 2, removed: 0, updated: 0, total: 2 });
assert.deepEqual(
body.models.map((model) => ({ id: model.id, name: model.name })),
[
{ id: "router-v2", name: "Router V2" },
{ id: "router-v3", name: "Router V3" },
]
);
assert.deepEqual(body.modelChanges, { added: 3, removed: 0, updated: 0, total: 3 });
assert.deepEqual(body.models, []);
assert.equal(aliases["stale-model"], undefined);
assert.equal(aliases["auto"], "openrouter/auto");
assert.equal(aliases["openrouter-router-v2"], "openrouter/router-v2");
assert.equal(aliases["router-v3"], "openrouter/router-v3");
assert.equal(logs.length, 1);
@@ -782,6 +810,7 @@ test("model sync route reports synced managed models separately from preserved m
});
await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual");
await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual");
globalThis.fetch = async (url) => {
assert.equal(
@@ -807,12 +836,17 @@ test("model sync route reports synced managed models separately from preserved m
assert.equal(body.availableModelsCount, 2);
assert.equal(body.importedCount, 1);
assert.equal(body.updatedCount, 0);
assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 });
assert.deepEqual(
body.models.map((model) => ({ id: model.id, source: model.source })),
[
{ id: "manual-only", source: "manual" },
{ id: "router-v4", source: "api-sync" },
]
[{ id: "manual-only", source: "manual" }]
);
assert.deepEqual(
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
source: model.source,
})),
[{ id: "router-v4", source: "imported" }]
);
});
@@ -868,33 +902,71 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi
assert.equal(aliases["cm-sonnet-4-6"], "anthropic-compatible-demo/sonnet-4-6");
});
test("model sync route returns 500 and records a failure when the internal models fetch throws", async () => {
test("model sync route falls back to in-process discovery when internal self-fetch throws", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openrouter",
provider: "openai-compatible-aio",
authType: "apikey",
name: "Exploding Sync",
name: "AIO Import",
apiKey: "test-key",
providerSpecificData: {
prefix: "aio",
apiType: "chat",
baseUrl: "https://api.bltcy.ai/v1",
nodeName: "aio",
autoSync: true,
},
});
globalThis.fetch = async () => {
throw new Error("network exploded");
const fetchCalls: string[] = [];
globalThis.fetch = async (url) => {
const urlString = String(url);
fetchCalls.push(urlString);
if (urlString === `http://localhost/api/providers/${connection.id}/models?refresh=true`) {
throw new Error("fetch failed");
}
assert.equal(urlString, "https://api.bltcy.ai/v1/models");
return Response.json({
data: [{ id: "aio-model", name: "AIO Model" }],
});
};
const response = await modelSyncRoute.POST(
new Request(`http://localhost/api/providers/${connection.id}/sync-models`, {
new Request(`http://localhost/api/providers/${connection.id}/sync-models?mode=import`, {
method: "POST",
headers: scheduler.buildModelSyncInternalHeaders(),
}),
{ params: { id: connection.id } }
);
const body = (await response.json()) as any;
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
const body = (await response.json()) as {
importedCount: number;
importedModels: Array<{ id: string; source: string }>;
};
const customModels = (await modelsDb.getCustomModels("openai-compatible-aio")) as Array<{
id: string;
source: string;
}>;
const availableModels = await modelsDb.getSyncedAvailableModels("openai-compatible-aio");
assert.equal(response.status, 500);
assert.equal(body.error, "network exploded");
assert.equal(logs.length, 1);
assert.equal(logs[0].status, 500);
assert.equal(logs[0].provider, "openrouter");
assert.equal(response.status, 200);
assert.equal(body.importedCount, 1);
assert.deepEqual(
body.importedModels.map((model) => ({ id: model.id, source: model.source })),
[{ id: "aio-model", source: "imported" }]
);
assert.deepEqual(
customModels.map((model) => ({ id: model.id, source: model.source })),
[]
);
assert.deepEqual(
availableModels.map((model) => ({ id: model.id, source: model.source })),
[{ id: "aio-model", source: "imported" }]
);
assert.deepEqual(fetchCalls, [
`http://localhost/api/providers/${connection.id}/models?refresh=true`,
"https://api.bltcy.ai/v1/models",
]);
});

View File

@@ -354,21 +354,21 @@ test("v1 models catalog includes synced Gemini models and duplicates audio model
{
id: "gemini-audio-live",
name: "Gemini Audio Live",
source: "api-sync",
source: "imported",
supportedEndpoints: ["audio"],
inputTokenLimit: 4096,
},
{
id: "text-embedding-004",
name: "Text Embedding 004",
source: "api-sync",
source: "imported",
supportedEndpoints: ["embeddings"],
inputTokenLimit: 2048,
},
{
id: "gemini-hidden",
name: "Gemini Hidden",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat"],
},
]
@@ -402,7 +402,7 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a
{
id: "gemini-2.5-pro-live",
name: "Gemini 2.5 Pro Live",
source: "api-sync",
source: "imported",
inputTokenLimit: 8192,
},
]);
@@ -430,7 +430,7 @@ test("v1 models catalog includes synced non-Gemini provider models from discover
{
id: "glm-5.1",
name: "GLM 5.1",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat"],
inputTokenLimit: 262144,
},

View File

@@ -461,7 +461,7 @@ test("provider models route caches discovered opencode-go models per connection"
assert.equal(firstResponse.status, 200);
assert.equal(firstBody.source, "api");
assert.deepEqual(firstBody.models, [{ id: "glm-5.1", name: "GLM 5.1" }]);
assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]);
assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]);
globalThis.fetch = async () => {
throw new Error("cached route should not hit upstream");
@@ -472,7 +472,7 @@ test("provider models route caches discovered opencode-go models per connection"
assert.equal(cachedResponse.status, 200);
assert.equal(cachedBody.source, "cache");
assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]);
assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]);
assert.equal(fetchCalls, 1);
});
@@ -481,7 +481,7 @@ test("provider models route falls back to cached models when a refresh fails", a
apiKey: "opencode-go-key",
});
await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [
{ id: "cached-go", name: "Cached Go", source: "api-sync" },
{ id: "cached-go", name: "Cached Go", source: "imported" },
]);
let fetchCalls = 0;
@@ -496,7 +496,7 @@ test("provider models route falls back to cached models when a refresh fails", a
assert.equal(response.status, 200);
assert.equal(body.source, "cache");
assert.match(body.warning, /cached catalog/i);
assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "api-sync" }]);
assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "imported" }]);
assert.equal(fetchCalls, 1);
});
@@ -505,7 +505,7 @@ test("provider models route clears cached discovery when a refresh returns no re
apiKey: "opencode-go-key",
});
await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [
{ id: "cached-go", name: "Cached Go", source: "api-sync" },
{ id: "cached-go", name: "Cached Go", source: "imported" },
]);
globalThis.fetch = async () => {

View File

@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1beta-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "v1beta-models-test-secret";
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const v1betaModelsRoute = await import("../../src/app/api/v1beta/models/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("v1beta models route deduplicates custom models against built-in and synced entries", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-main", [
{
id: "gpt-4o",
name: "GPT-4o From Sync",
source: "imported",
},
{
id: "review-sync-only",
name: "Review Sync Only",
source: "imported",
},
]);
await modelsDb.addCustomModel("openai", "gpt-4o", "GPT-4o Manual Duplicate");
await modelsDb.addCustomModel("openai", "review-sync-only", "Review Manual Duplicate");
await modelsDb.addCustomModel("openai", "review-manual-only", "Review Manual Only");
const response = await v1betaModelsRoute.GET();
const body = (await response.json()) as { models: Array<{ name: string }> };
const names = body.models.map((model) => model.name);
assert.equal(response.status, 200);
assert.equal(names.filter((name) => name === "models/openai/gpt-4o").length, 1);
assert.equal(names.filter((name) => name === "models/openai/review-sync-only").length, 1);
assert.equal(names.filter((name) => name === "models/openai/review-manual-only").length, 1);
});