diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
index f1be9646e3..c310668c9f 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
@@ -1480,6 +1480,35 @@ export default function ProviderDetailPage() {
const canImportModels = connections.some((conn) => conn.isActive !== false);
+ // Auto-sync toggle state: read from first active connection's providerSpecificData
+ const autoSyncConnection = connections.find((conn: any) => conn.isActive !== false);
+ const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync;
+ const [togglingAutoSync, setTogglingAutoSync] = useState(false);
+
+ const handleToggleAutoSync = async () => {
+ if (!autoSyncConnection || togglingAutoSync) return;
+ setTogglingAutoSync(true);
+ try {
+ const newValue = !isAutoSyncEnabled;
+ await fetch(`/api/providers/${(autoSyncConnection as any).id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ providerSpecificData: { autoSync: newValue },
+ }),
+ });
+ await fetchConnections();
+ notify[newValue ? "success" : "info"](
+ newValue ? t("autoSyncEnabled") : t("autoSyncDisabled")
+ );
+ } catch (error) {
+ console.log("Error toggling auto-sync:", error);
+ notify.error(t("autoSyncToggleFailed"));
+ } finally {
+ setTogglingAutoSync(false);
+ }
+ };
+
const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]);
const overrideMap = useMemo(
() => buildCompatMap(modelMeta.modelCompatOverrides),
@@ -1594,6 +1623,23 @@ export default function ProviderDetailPage() {
/>
);
}
+ const autoSyncToggle = canImportModels && (
+
+ );
+
if (providerInfo.passthroughModels) {
return (
@@ -1607,6 +1653,7 @@ export default function ProviderDetailPage() {
>
{importingModels ? t("importingModels") : t("importFromModels")}
+ {autoSyncToggle}
{!canImportModels && (
{t("addConnectionToImport")}
)}
@@ -1640,6 +1687,7 @@ export default function ProviderDetailPage() {
>
{importingModels ? t("importingModels") : t("importFromModels")}
+ {autoSyncToggle}
{!canImportModels && (
{t("addConnectionToImport")}
)}
diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts
new file mode 100644
index 0000000000..e5ebdfd51e
--- /dev/null
+++ b/src/app/api/providers/[id]/sync-models/route.ts
@@ -0,0 +1,119 @@
+import { NextResponse } from "next/server";
+import { getProviderConnectionById } from "@/models";
+import { replaceCustomModels } from "@/lib/db/models";
+import { saveCallLog } from "@/lib/usage/callLogs";
+import { isAuthenticated } from "@/shared/utils/apiAuth";
+
+/**
+ * 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. Logs the operation to call_logs.
+ *
+ * Used by:
+ * - modelSyncScheduler (auto-sync on interval)
+ * - Manual trigger from UI
+ */
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const start = Date.now();
+ const { id } = await params;
+
+ try {
+ if (!(await isAuthenticated(request))) {
+ return NextResponse.json(
+ { error: { message: "Authentication required", type: "invalid_api_key" } },
+ { status: 401 }
+ );
+ }
+
+ const connection = await getProviderConnectionById(id);
+ if (!connection) {
+ return NextResponse.json({ error: "Connection not found" }, { status: 404 });
+ }
+
+ // Fetch models from the existing /api/providers/[id]/models endpoint
+ const origin = new URL(request.url).origin;
+ const modelsUrl = `${origin}/api/providers/${id}/models`;
+ const modelsRes = await fetch(modelsUrl, {
+ method: "GET",
+ headers: {
+ cookie: request.headers.get("cookie") || "",
+ "x-internal": "model-sync",
+ },
+ });
+
+ const duration = Date.now() - start;
+ const modelsData = await modelsRes.json();
+
+ if (!modelsRes.ok) {
+ // Log the failed attempt
+ await saveCallLog({
+ method: "GET",
+ path: `/api/providers/${id}/models`,
+ status: modelsRes.status,
+ model: "model-sync",
+ provider: connection.provider || "unknown",
+ connectionId: id,
+ duration,
+ error: modelsData.error || `HTTP ${modelsRes.status}`,
+ requestType: "model-sync",
+ });
+
+ return NextResponse.json(
+ { error: modelsData.error || "Failed to fetch models" },
+ { status: modelsRes.status }
+ );
+ }
+
+ const fetchedModels = modelsData.models || [];
+
+ // 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",
+ }))
+ .filter((m: any) => m.id);
+
+ const replaced = await replaceCustomModels(connection.provider, models);
+
+ // Log the successful sync
+ await saveCallLog({
+ method: "GET",
+ path: `/api/providers/${id}/models`,
+ status: 200,
+ model: "model-sync",
+ provider: connection.provider || "unknown",
+ connectionId: id,
+ duration: Date.now() - start,
+ requestType: "model-sync",
+ responseBody: {
+ syncedModels: models.length,
+ provider: connection.provider,
+ },
+ });
+
+ return NextResponse.json({
+ ok: true,
+ provider: connection.provider,
+ syncedModels: replaced.length,
+ models: replaced,
+ });
+ } catch (error: any) {
+ // Log error
+ await saveCallLog({
+ method: "POST",
+ path: `/api/providers/${id}/sync-models`,
+ status: 500,
+ model: "model-sync",
+ provider: "unknown",
+ connectionId: id,
+ duration: Date.now() - start,
+ error: error.message || "Sync failed",
+ requestType: "model-sync",
+ }).catch(() => {});
+
+ return NextResponse.json({ error: error.message || "Failed to sync models" }, { status: 500 });
+ }
+}
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 42612d5efa..02083438af 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1378,6 +1378,11 @@
"chatCompletions": "Chat Completions",
"importingModels": "Importing...",
"importFromModels": "Import from /models",
+ "autoSync": "Auto-Sync",
+ "autoSyncTooltip": "Automatically refresh model list every 24h (configurable via MODEL_SYNC_INTERVAL_HOURS)",
+ "autoSyncEnabled": "Auto-sync enabled — models will refresh periodically",
+ "autoSyncDisabled": "Auto-sync disabled",
+ "autoSyncToggleFailed": "Failed to toggle auto-sync",
"addConnectionToImport": "Add a connection to enable importing.",
"noModelsConfigured": "No models configured",
"connectionCount": "{count} connection(s)",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index df39e09823..01d64dc86c 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1378,6 +1378,11 @@
"chatCompletions": "聊天完成",
"importingModels": "正在导入...",
"importFromModels": "从 /models 导入",
+ "autoSync": "自动同步",
+ "autoSyncTooltip": "每24小时自动刷新模型列表(可通过 MODEL_SYNC_INTERVAL_HOURS 配置)",
+ "autoSyncEnabled": "已启用自动同步 — 模型列表将定期刷新",
+ "autoSyncDisabled": "已禁用自动同步",
+ "autoSyncToggleFailed": "切换自动同步失败",
"addConnectionToImport": "添加连接以启用导入。",
"noModelsConfigured": "尚未配置模型",
"connectionCount": "{count} 连接",
diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts
index 351075aa59..8f8874760e 100644
--- a/src/lib/db/models.ts
+++ b/src/lib/db/models.ts
@@ -360,6 +360,76 @@ export async function addCustomModel(
return model;
}
+/**
+ * Replace the entire custom models list for a provider (used by auto-sync).
+ * Preserves per-model compatibility overrides for models that still exist.
+ */
+export async function replaceCustomModels(
+ providerId: string,
+ models: Array<{
+ id: string;
+ name?: string;
+ source?: string;
+ apiFormat?: string;
+ supportedEndpoints?: string[];
+ }>
+) {
+ const db = getDbInstance();
+ const existing = await getCustomModels(providerId);
+ const existingMap = new Map();
+ if (Array.isArray(existing)) {
+ for (const m of existing) {
+ if (m && typeof m === "object" && m.id) existingMap.set(m.id, m);
+ }
+ }
+
+ // Merge: keep existing per-model compat flags if model still exists
+ const merged = models.map((m) => {
+ const prev = existingMap.get(m.id);
+ return {
+ id: m.id,
+ name: m.name || m.id,
+ source: m.source || "auto-sync",
+ apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions",
+ supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"],
+ // Preserve existing compat flags
+ ...(prev && (prev as any).normalizeToolCallId !== undefined
+ ? { normalizeToolCallId: (prev as any).normalizeToolCallId }
+ : {}),
+ ...(prev && (prev as any).preserveOpenAIDeveloperRole !== undefined
+ ? { preserveOpenAIDeveloperRole: (prev as any).preserveOpenAIDeveloperRole }
+ : {}),
+ ...(prev && (prev as any).compatByProtocol
+ ? { compatByProtocol: (prev as any).compatByProtocol }
+ : {}),
+ ...(prev && (prev as any).upstreamHeaders
+ ? { upstreamHeaders: (prev as any).upstreamHeaders }
+ : {}),
+ };
+ });
+
+ if (merged.length === 0) {
+ db.prepare("DELETE FROM key_value WHERE namespace = 'customModels' AND key = ?").run(
+ providerId
+ );
+ } else {
+ db.prepare(
+ "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)"
+ ).run(providerId, JSON.stringify(merged));
+ }
+
+ // Remove compat overrides for models that no longer exist
+ const newIds = new Set(models.map((m) => m.id));
+ const compatList = readCompatList(providerId);
+ const filteredCompat = compatList.filter((e) => newIds.has(e.id));
+ if (filteredCompat.length !== compatList.length) {
+ writeCompatList(providerId, filteredCompat);
+ }
+
+ backupDbFile("pre-write");
+ return merged;
+}
+
export async function removeCustomModel(providerId, modelId) {
const db = getDbInstance();
const row = db
diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts
index 48feef87af..131295e137 100644
--- a/src/lib/localDb.ts
+++ b/src/lib/localDb.ts
@@ -48,6 +48,7 @@ export {
getCustomModels,
getAllCustomModels,
addCustomModel,
+ replaceCustomModels,
removeCustomModel,
updateCustomModel,
getModelCompatOverrides,
diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts
index 89fe1fe2cf..1fd37359ee 100644
--- a/src/shared/services/modelSyncScheduler.ts
+++ b/src/shared/services/modelSyncScheduler.ts
@@ -1,8 +1,9 @@
/**
* Model Auto-Sync Scheduler (#488)
*
- * Automatically refreshes model lists for all providers with autoSync enabled
- * at a configurable interval (default: 24h).
+ * Automatically refreshes model lists for provider connections that have
+ * autoSync enabled in their providerSpecificData, at a configurable
+ * interval (default: 24h).
*
* Pattern mirrors cloudSyncScheduler.ts for consistency.
*/
@@ -12,53 +13,67 @@ import { getSettings, updateSettings } from "@/lib/localDb";
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
-/** Providers that support live model list fetching via /v1/models */
-const AUTO_SYNC_PROVIDERS = [
- "openai",
- "anthropic",
- "google",
- "gemini",
- "deepseek",
- "groq",
- "mistral",
- "cohere",
- "openrouter",
- "together",
- "fireworks",
- "perplexity",
- "xai",
- "cerebras",
- "ollama",
- "nvidia",
-];
-
let schedulerTimer: NodeJS.Timeout | null = null;
let isRunning = false;
/**
- * Fetch and cache models for a single provider.
- * Calls the internal /api/providers/{id}/sync-models endpoint (if it exists)
- * or falls back to /v1/models from the provider registry.
+ * Fetch all provider connections that have autoSync enabled.
*/
-async function syncProviderModels(providerId: string, baseUrl: string): Promise {
+async function getAutoSyncConnections(): Promise<
+ Array<{ id: string; provider: string; name?: string }>
+> {
try {
- const res = await fetch(`${baseUrl}/api/provider-nodes/sync-models`, {
- method: "POST",
- headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" },
- body: JSON.stringify({ provider: providerId }),
+ const { getProviderConnections } = await import("@/lib/localDb");
+ const connections = await getProviderConnections();
+ return connections.filter((conn: any) => {
+ if (!conn.isActive && conn.isActive !== undefined) return false;
+ const psd =
+ conn.providerSpecificData && typeof conn.providerSpecificData === "object"
+ ? conn.providerSpecificData
+ : {};
+ return psd.autoSync === true;
});
- if (!res.ok) {
- console.warn(`[ModelSync] Provider ${providerId}: sync returned ${res.status}`);
- } else {
- console.log(`[ModelSync] Provider ${providerId}: ✓ updated`);
- }
} catch (err) {
- console.warn(`[ModelSync] Provider ${providerId}: fetch failed —`, (err as Error).message);
+ console.warn("[ModelSync] Failed to load connections:", (err as Error).message);
+ return [];
}
}
/**
- * Run one full model-sync cycle across all auto-sync providers.
+ * Sync models for a single connection via the internal sync-models endpoint.
+ */
+async function syncConnectionModels(
+ connectionId: string,
+ provider: string,
+ baseUrl: string
+): Promise {
+ try {
+ const res = await fetch(`${baseUrl}/api/providers/${connectionId}/sync-models`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" },
+ });
+ if (!res.ok) {
+ console.warn(
+ `[ModelSync] ${provider} (${connectionId.slice(0, 8)}): sync returned ${res.status}`
+ );
+ return false;
+ }
+ const data = await res.json();
+ console.log(
+ `[ModelSync] ${provider} (${connectionId.slice(0, 8)}): ✓ ${data.syncedModels || 0} models`
+ );
+ return true;
+ } catch (err) {
+ console.warn(
+ `[ModelSync] ${provider} (${connectionId.slice(0, 8)}): fetch failed —`,
+ (err as Error).message
+ );
+ return false;
+ }
+}
+
+/**
+ * Run one full model-sync cycle across all auto-sync connections.
*/
async function runSyncCycle(apiBaseUrl: string): Promise {
if (isRunning) {
@@ -67,26 +82,35 @@ async function runSyncCycle(apiBaseUrl: string): Promise {
}
isRunning = true;
const start = Date.now();
- console.log(
- `[ModelSync] Starting 24h model sync cycle — ${AUTO_SYNC_PROVIDERS.length} providers`
- );
- const results = await Promise.allSettled(
- AUTO_SYNC_PROVIDERS.map((id) => syncProviderModels(id, apiBaseUrl))
- );
-
- const succeeded = results.filter((r) => r.status === "fulfilled").length;
- console.log(
- `[ModelSync] Cycle complete: ${succeeded}/${AUTO_SYNC_PROVIDERS.length} providers synced in ${Date.now() - start}ms`
- );
-
- // Record last sync time
try {
- await updateSettings({ [MODEL_SYNC_SETTING_KEY]: new Date().toISOString() });
- } catch {
- // Non-critical
+ const connections = await getAutoSyncConnections();
+
+ if (connections.length === 0) {
+ console.log("[ModelSync] No connections with autoSync enabled — skipping cycle");
+ return;
+ }
+
+ console.log(`[ModelSync] Starting model sync cycle — ${connections.length} connection(s)`);
+
+ const results = await Promise.allSettled(
+ connections.map((conn) => syncConnectionModels(conn.id, conn.provider, apiBaseUrl))
+ );
+
+ const succeeded = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
+ console.log(
+ `[ModelSync] Cycle complete: ${succeeded}/${connections.length} synced in ${Date.now() - start}ms`
+ );
+
+ // Record last sync time
+ try {
+ await updateSettings({ [MODEL_SYNC_SETTING_KEY]: new Date().toISOString() });
+ } catch {
+ // Non-critical
+ }
+ } finally {
+ isRunning = false;
}
- isRunning = false;
}
/**
@@ -108,9 +132,7 @@ export function startModelSyncScheduler(
const effectiveIntervalMs =
!isNaN(envHours) && envHours > 0 ? envHours * 60 * 60 * 1000 : intervalMs;
- console.log(
- `[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h, providers: ${AUTO_SYNC_PROVIDERS.length}`
- );
+ console.log(`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h`);
// Run immediately on startup (staggered by 5s to avoid startup congestion)
const startupDelay = setTimeout(() => runSyncCycle(apiBaseUrl), 5_000);