Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 33 additions & 79 deletions src/app/(dashboard)/dashboard/providers/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,7 @@ interface CompatibleModelsSectionProps {
onDeleteAlias: (alias: string) => void;
connections: { id?: string; isActive?: boolean }[];
isAnthropic?: boolean;
onImportWithProgress: (
fetchModels: () => Promise<{ models: unknown[] }>,
processModel: (model: unknown) => Promise<boolean>
) => Promise<void>;
onImportWithProgress: (connectionId: string) => Promise<void>;
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
Expand Down Expand Up @@ -2004,10 +2001,7 @@ export default function ProviderDetailPage() {
};

// Shared import handler for CompatibleModelsSection
const handleCompatibleImportWithProgress = async (
fetchModels: () => Promise<{ models: any[] }>,
processModel: (model: any) => Promise<boolean>
) => {
const handleCompatibleImportWithProgress = async (connectionId: string) => {
setShowImportModal(true);
setImportProgress({
current: 0,
Expand All @@ -2020,53 +2014,51 @@ export default function ProviderDetailPage() {
});

try {
const data = await fetchModels();
const models = data.models || [];
if (models.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status: t("noModelsFound"),
logs: [t("noModelsReturnedFromEndpoint")],
}));
return;
const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, {
method: "POST",
signal: AbortSignal.timeout(60_000),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t("failedImportModels"));
}

setImportProgress((prev) => ({
...prev,
phase: "importing",
total: models.length,
status: t("importingModelsProgress", { current: 0, total: models.length }),
logs: [t("foundModelsStartingImport", { count: models.length })],
}));

let importedCount = 0;
for (let i = 0; i < models.length; i++) {
const model = models[i];
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
const importedModels = Array.isArray(data.importedModels) ? data.importedModels : [];
const importedCount =
typeof data.importedCount === "number" ? data.importedCount : importedModels.length;

if (importedModels.length === 0) {
setImportProgress((prev) => ({
...prev,
current: i + 1,
status: t("importingModelsProgress", { current: i + 1, total: models.length }),
logs: [...prev.logs, t("importingModelById", { modelId })],
phase: "done",
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAdded"),
logs: [
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));

const added = await processModel(model);
if (added) importedCount += 1;
return;
}

setImportProgress((prev) => ({
...prev,
phase: "done",
current: models.length,
total: importedModels.length,
current: importedModels.length,
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAdded"),
logs: [
...prev.logs,
t("foundModelsStartingImport", { count: importedModels.length }),
...importedModels.map((model: any) =>
t("importingModelById", { modelId: model.id || model.name || model.model })
),
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
Expand Down Expand Up @@ -4493,49 +4485,11 @@ function CompatibleModelsSection({
const handleImport = async () => {
if (!allowImport || importing) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection) return;
if (!activeConnection?.id) return;

setImporting(true);
try {
const workingAliases = { ...modelAliases };
await onImportWithProgress(
// fetchModels callback
async () => {
const res = await fetch(`/api/providers/${activeConnection.id}/models?refresh=true`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || t("failedImportModels"));
return data;
},
// processModel callback
async (model: any) => {
const modelId = model.id || model.name || model.model;
if (!modelId) return false;
const resolvedAlias = resolveAlias(modelId, workingAliases);
if (!resolvedAlias) return false;

// Save to customModels DB FIRST - only create alias if this succeeds
const customModelRes = await fetch("/api/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerStorageAlias,
modelId,
modelName: model.name || modelId,
source: "imported",
}),
});

if (!customModelRes.ok) {
notify.error(t("failedSaveImportedModel"));
return false;
}

// Only create alias after customModel is saved successfully
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
workingAliases[resolvedAlias] = `${providerStorageAlias}/${modelId}`;
return true;
}
);
await onImportWithProgress(activeConnection.id);
} catch (error) {
console.error("Error importing models:", error);
notify.error(t("failedImportModelsTryAgain"));
Expand Down
16 changes: 10 additions & 6 deletions src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,9 +986,14 @@ export async function GET(
return buildApiDiscoveryResponse(models);
}

const config =
provider in PROVIDER_MODELS_CONFIG
? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG]
: undefined;

// Static model providers (no remote /models API)
const staticModels = getStaticModelsForProvider(provider);
if (staticModels) {
if (!config && staticModels) {
return buildResponse({
provider,
connectionId,
Expand All @@ -1011,11 +1016,10 @@ export async function GET(
});
}

const config =
provider in PROVIDER_MODELS_CONFIG
? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG]
: undefined;
const localCatalog = getStaticModelsForProvider(provider) || PROVIDER_MODELS[provider] || [];
const localCatalog =
(config
? PROVIDER_MODELS[provider] || staticModels
: staticModels || PROVIDER_MODELS[provider]) || [];
if (!config && localCatalog.length > 0) {
return buildResponse({
provider,
Expand Down
88 changes: 34 additions & 54 deletions src/app/api/providers/[id]/sync-models/route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { getCustomModels } from "@/lib/db/models";
import {
getCustomModels,
replaceCustomModels,
replaceSyncedAvailableModelsForConnection,
} from "@/lib/db/models";
import {
syncManagedAvailableModelAliases,
usesManagedAvailableModels,
} from "@/lib/providerModels/managedAvailableModels";
import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery";
importManagedModels,
type ManagedModelImportMode,
} from "@/lib/providerModels/managedModelImport";
import { saveCallLog } from "@/lib/usage/callLogs";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
buildModelSyncInternalHeaders,
isModelSyncInternalRequest,
} from "@/shared/services/modelSyncScheduler";
import { getModelsByProviderId } from "@/shared/constants/models";

type JsonRecord = Record<string, unknown>;

Expand All @@ -32,7 +26,11 @@ function normalizeModelForComparison(model: unknown) {
const record = asRecord(model);
const id = toNonEmptyString(record.id) || "";
const name = toNonEmptyString(record.name) || id;
const source = toNonEmptyString(record.source) || "auto-sync";
const rawSource = toNonEmptyString(record.source)?.toLowerCase();
const source =
rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported"
? "api-sync"
: rawSource || "auto-sync";
const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions";
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
Expand Down Expand Up @@ -129,6 +127,9 @@ function getModelSyncChannelLabel(connection: unknown) {
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
Comment on lines 120 to 132

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per repo guidelines, PRs that change production code under src/ should run the coverage gate (npm run test:coverage) and maintain ≥60% statements/lines/functions/branches. The PR description lists individual unit tests but not the coverage run/result; please add the coverage run output (or update tests until it passes) before merging.

Copilot generated this review using guidance from repository custom instructions.
const start = Date.now();
const { id } = await params;
const mode = (
new URL(request.url).searchParams.get("mode") === "import" ? "merge" : "sync"
) as ManagedModelImportMode;
let logProvider = "unknown";
let channelLabel: string | null = null;

Expand Down Expand Up @@ -192,48 +193,22 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
}

const fetchedModels = modelsData.models || [];

// Filter out models already in the built-in registry
const registryIds = new Set(getModelsByProviderId(logProvider).map((m: any) => m.id));

// Replace the full model list
const models = fetchedModels
.map((m: any) => ({
id: m.id || m.name || m.model,
name: m.name || m.displayName || m.id || m.model,
source: "auto-sync",
...(Array.isArray(m.supportedEndpoints) && m.supportedEndpoints.length > 0
? { supportedEndpoints: m.supportedEndpoints }
: {}),
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.supportsThinking === true ? { supportsThinking: true } : {}),
}))
.filter((m: any) => m.id && !registryIds.has(m.id));

const previousModels = await getCustomModels(logProvider);
const replaced = await replaceCustomModels(logProvider, models);

try {
const syncedModels = normalizeDiscoveredModels(fetchedModels);
if (syncedModels.length > 0) {
await replaceSyncedAvailableModelsForConnection(logProvider, id, syncedModels);
}
} catch (e) {
console.error(`Failed to union synced available models for ${logProvider}:`, e);
}

const modelChanges = summarizeModelChanges(previousModels, replaced);
const { persistedModels, importedModels, discoveredModels, syncedAliases, importedChanges } =
await importManagedModels({
providerId: logProvider,
connectionId: id,
fetchedModels,
mode,
});

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

importManagedModels() already fetches and returns previousModels, but this route fetches previousModels again via getCustomModels(logProvider). This adds an extra DB read per sync/import and can also make the diff calculation inconsistent if models change between the two reads. Consider returning previousModels from importManagedModels and using that value here (or passing previousModels into importManagedModels).

Copilot uses AI. Check for mistakes.

let syncedAliases = 0;
if (usesManagedAvailableModels(logProvider)) {
const aliasSync = await syncManagedAvailableModelAliases(
logProvider,
models.map((model: any) => model.id)
);
syncedAliases = aliasSync.assignedAliases.length;
}
const modelChanges = summarizeModelChanges(previousModels, persistedModels);
const syncedModelsCount =
persistedModels.length > 0
? persistedModels.length
: importedModels.length > 0
? importedModels.length
: discoveredModels.length;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fallback logic for syncedModelsCount is misleading. It should directly reflect the number of models returned in the models field of the response (which is persistedModels.length). Falling back to discoveredModels.length when persistedModels is empty would cause the count to include built-in models that were not actually persisted as custom models, leading to inconsistent API responses and confusing logs.

Suggested change
const syncedModelsCount =
persistedModels.length > 0
? persistedModels.length
: importedModels.length > 0
? importedModels.length
: discoveredModels.length;
const syncedModelsCount = persistedModels.length;

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncedModelsCount is computed from persistedModels.length first, but persistedModels includes retained manual/custom models (especially in mode === "merge" and also in sync where manual models are preserved). This will over-report the number of models actually synced/imported in logs and API responses. Consider using importedModels.length (models written by this sync) or tracking a dedicated count of synced/imported models separately from the total persisted list.

Suggested change
const syncedModelsCount =
persistedModels.length > 0
? persistedModels.length
: importedModels.length > 0
? importedModels.length
: discoveredModels.length;
const syncedModelsCount = importedModels.length;

Copilot uses AI. Check for mistakes.

if (modelChanges.total > 0) {
await saveCallLog({
Expand All @@ -247,23 +222,28 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
duration: Date.now() - start,
requestType: "model-sync",
responseBody: {
syncedModels: models.length,
syncedModels: syncedModelsCount,
syncedAliases,
provider: logProvider,
channel: channelLabel,
modelChanges,
mode,
},
});
}

return NextResponse.json({
ok: true,
provider: logProvider,
syncedModels: replaced.length,
mode,
syncedModels: syncedModelsCount,
syncedAliases,
modelChanges,
importedCount: importedChanges.total,

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

importedCount is derived from importedChanges.total (added + updated). The UI strings (importSuccessCount, etc.) read like “new models imported”, so counting updates as imports may produce misleading success messages. Consider changing importedCount to importedChanges.added, or return separate importedAddedCount / importedUpdatedCount fields and have the UI pick the appropriate one.

Suggested change
importedCount: importedChanges.total,
importedCount: importedChanges.added,

Copilot uses AI. Check for mistakes.
importedChanges,
logged: modelChanges.total > 0,
models: replaced,
models: persistedModels,
importedModels,
});
} catch (error: any) {
// Log error
Expand Down
20 changes: 13 additions & 7 deletions src/lib/providerModels/managedAvailableModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ async function getProviderDisplayPrefix(providerId: string): Promise<string> {
return typeof prefix === "string" && prefix.trim().length > 0 ? prefix.trim() : providerId;
}

export async function syncManagedAvailableModelAliases(providerId: string, modelIds: string[]) {
export async function syncManagedAvailableModelAliases(
providerId: string,
modelIds: string[],
{ pruneMissing = true }: { pruneMissing?: boolean } = {}
) {
const storagePrefix = getProviderStoragePrefix(providerId);
const displayPrefix = await getProviderDisplayPrefix(providerId);
const existingAliasesRaw = await getModelAliases();
Expand All @@ -53,13 +57,15 @@ export async function syncManagedAvailableModelAliases(providerId: string, model
const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`));
const removedAliases: string[] = [];

for (const [alias, value] of Object.entries(workingAliases)) {
if (!value.startsWith(`${storagePrefix}/`)) continue;
if (targetFullModels.has(value)) continue;
if (pruneMissing) {
for (const [alias, value] of Object.entries(workingAliases)) {
if (!value.startsWith(`${storagePrefix}/`)) continue;
if (targetFullModels.has(value)) continue;

await deleteModelAlias(alias);
delete workingAliases[alias];
removedAliases.push(alias);
await deleteModelAlias(alias);
delete workingAliases[alias];
removedAliases.push(alias);
}
}

const assignedAliases: string[] = [];
Expand Down
Loading