mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
119
src/app/api/providers/[id]/sync-models/route.ts
Normal file
119
src/app/api/providers/[id]/sync-models/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -1378,6 +1378,11 @@
|
||||
"chatCompletions": "聊天完成",
|
||||
"importingModels": "正在导入...",
|
||||
"importFromModels": "从 /models 导入",
|
||||
"autoSync": "自动同步",
|
||||
"autoSyncTooltip": "每24小时自动刷新模型列表(可通过 MODEL_SYNC_INTERVAL_HOURS 配置)",
|
||||
"autoSyncEnabled": "已启用自动同步 — 模型列表将定期刷新",
|
||||
"autoSyncDisabled": "已禁用自动同步",
|
||||
"autoSyncToggleFailed": "切换自动同步失败",
|
||||
"addConnectionToImport": "添加连接以启用导入。",
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"connectionCount": "{count} 连接",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
getCustomModels,
|
||||
getAllCustomModels,
|
||||
addCustomModel,
|
||||
replaceCustomModels,
|
||||
removeCustomModel,
|
||||
updateCustomModel,
|
||||
getModelCompatOverrides,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user