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 <noreply@anthropic.com>
This commit is contained in:
R.D.
2026-03-24 21:16:09 -04:00
parent 9e640cac6b
commit 6eda0f4d00
7 changed files with 327 additions and 57 deletions

View File

@@ -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 && (
<button
onClick={handleToggleAutoSync}
disabled={togglingAutoSync}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-border bg-transparent cursor-pointer text-[12px] disabled:opacity-50 disabled:cursor-not-allowed"
title={t("autoSyncTooltip")}
>
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isAutoSyncEnabled ? "#22c55e" : "var(--color-text-muted)" }}
>
{isAutoSyncEnabled ? "toggle_on" : "toggle_off"}
</span>
<span className="text-text-main">{t("autoSync")}</span>
</button>
);
if (providerInfo.passthroughModels) {
return (
<div>
@@ -1607,6 +1653,7 @@ export default function ProviderDetailPage() {
>
{importingModels ? t("importingModels") : t("importFromModels")}
</Button>
{autoSyncToggle}
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
@@ -1640,6 +1687,7 @@ export default function ProviderDetailPage() {
>
{importingModels ? t("importingModels") : t("importFromModels")}
</Button>
{autoSyncToggle}
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}

View File

@@ -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 });
}
}

View File

@@ -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)",

View File

@@ -1378,6 +1378,11 @@
"chatCompletions": "聊天完成",
"importingModels": "正在导入...",
"importFromModels": "从 /models 导入",
"autoSync": "自动同步",
"autoSyncTooltip": "每24小时自动刷新模型列表可通过 MODEL_SYNC_INTERVAL_HOURS 配置)",
"autoSyncEnabled": "已启用自动同步 — 模型列表将定期刷新",
"autoSyncDisabled": "已禁用自动同步",
"autoSyncToggleFailed": "切换自动同步失败",
"addConnectionToImport": "添加连接以启用导入。",
"noModelsConfigured": "尚未配置模型",
"connectionCount": "{count} 连接",

View File

@@ -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<string, JsonRecord>();
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

View File

@@ -48,6 +48,7 @@ export {
getCustomModels,
getAllCustomModels,
addCustomModel,
replaceCustomModels,
removeCustomModel,
updateCustomModel,
getModelCompatOverrides,

View File

@@ -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<void> {
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<boolean> {
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<void> {
if (isRunning) {
@@ -67,26 +82,35 @@ async function runSyncCycle(apiBaseUrl: string): Promise<void> {
}
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);