From 6eda0f4d00dc257bf33902491e429042de829b3f Mon Sep 17 00:00:00 2001 From: "R.D." Date: Tue, 24 Mar 2026 21:16:09 -0400 Subject: [PATCH] feat: add per-provider auto-sync for model lists - Add POST /api/providers/[id]/sync-models endpoint that fetches models from a provider's /models API and replaces the full custom models list, preserving per-model compatibility overrides - Rewrite modelSyncScheduler to dynamically discover connections with autoSync enabled in providerSpecificData instead of a hardcoded list - Add replaceCustomModels() to db/models.ts for full list replacement while preserving existing compat flags - Log each model sync operation to call_logs for visibility in the Logs page - Add Auto-Sync toggle button next to "Import from /models" in the provider detail page UI - Add en/zh-CN i18n translations for auto-sync strings Co-Authored-By: Claude Opus 4.6 --- .../dashboard/providers/[id]/page.tsx | 48 +++++++ .../api/providers/[id]/sync-models/route.ts | 119 +++++++++++++++ src/i18n/messages/en.json | 5 + src/i18n/messages/zh-CN.json | 5 + src/lib/db/models.ts | 70 +++++++++ src/lib/localDb.ts | 1 + src/shared/services/modelSyncScheduler.ts | 136 ++++++++++-------- 7 files changed, 327 insertions(+), 57 deletions(-) create mode 100644 src/app/api/providers/[id]/sync-models/route.ts 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);