mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
55
tests/unit/v1beta-models-route.test.ts
Normal file
55
tests/unit/v1beta-models-route.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user