diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e4592ae38..4ffe181cbb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -274,8 +274,9 @@ Domain State DB (SQLite): ## 5) Cloud Sync -- Scheduler init: `src/lib/initCloudSync.ts`, `src/shared/services/initializeCloudSync.ts` +- Scheduler init: `src/lib/initCloudSync.ts`, `src/shared/services/initializeCloudSync.ts`, `src/shared/services/modelSyncScheduler.ts` - Periodic task: `src/shared/services/cloudSyncScheduler.ts` +- Periodic task: `src/shared/services/modelSyncScheduler.ts` - Control route: `src/app/api/sync/cloud/route.ts` ## Request Lifecycle (`/v1/chat/completions`) diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 40e3bc8615..c86fd1ad52 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -3,6 +3,10 @@ import { getProviderConnectionById } from "@/models"; import { replaceCustomModels } from "@/lib/db/models"; import { saveCallLog } from "@/lib/usage/callLogs"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + buildModelSyncInternalHeaders, + isModelSyncInternalRequest, +} from "@/shared/services/modelSyncScheduler"; /** * POST /api/providers/[id]/sync-models @@ -19,7 +23,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { id } = await params; try { - if (!(await isAuthenticated(request))) { + if (!(await isAuthenticated(request)) && !isModelSyncInternalRequest(request)) { return NextResponse.json( { error: { message: "Authentication required", type: "invalid_api_key" } }, { status: 401 } @@ -41,7 +45,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: method: "GET", headers: { cookie: request.headers.get("cookie") || "", - "x-internal": "model-sync", + ...buildModelSyncInternalHeaders(), }, }); diff --git a/src/lib/initCloudSync.ts b/src/lib/initCloudSync.ts index 70cf19e99c..63c292be0b 100644 --- a/src/lib/initCloudSync.ts +++ b/src/lib/initCloudSync.ts @@ -1,16 +1,18 @@ import initializeCloudSync from "@/shared/services/initializeCloudSync"; +import { startModelSyncScheduler } from "@/shared/services/modelSyncScheduler"; import "@/lib/tokenHealthCheck"; // Proactive token health-check scheduler -// Initialize cloud sync when this module is imported +// Initialize background sync services when this module is imported let initialized = false; export async function ensureCloudSyncInitialized() { if (!initialized) { try { await initializeCloudSync(); + startModelSyncScheduler(); initialized = true; } catch (error) { - console.error("[ServerInit] Error initializing cloud sync:", error); + console.error("[ServerInit] Error initializing background sync services:", error); } } return initialized; diff --git a/src/proxy.ts b/src/proxy.ts index 7f3cd981f3..6971a30b47 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,6 +5,7 @@ import { getSettings } from "./lib/localDb"; import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth"; import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard"; import { isDraining } from "./lib/gracefulShutdown"; +import { isModelSyncInternalRequest } from "./shared/services/modelSyncScheduler"; const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || ""); @@ -43,6 +44,14 @@ export async function proxy(request) { return response; } + // Allow the model auto-sync scheduler to reach only its internal provider routes. + if ( + isModelSyncInternalRequest(request) && + /^\/api\/providers\/[^/]+\/(sync-models|models)$/.test(pathname) + ) { + return response; + } + // Check if auth is required at all (respects requireLogin setting) const authRequired = await isAuthRequired(); if (!authRequired) { diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index 45601209bf..67f7d3b147 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -8,13 +8,53 @@ * Pattern mirrors cloudSyncScheduler.ts for consistency. */ +import { randomUUID } from "node:crypto"; import { getSettings, updateSettings } from "@/lib/localDb"; +import { getRuntimePorts } from "@/lib/runtime/ports"; const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const MODEL_SYNC_SETTING_KEY = "model_sync_last_run"; +const MODEL_SYNC_INTERNAL_AUTH_HEADER = "x-model-sync-internal-auth"; + +const { dashboardPort } = getRuntimePorts(); + +const INTERNAL_BASE_URL = + process.env.BASE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.NEXT_PUBLIC_APP_URL || + `http://localhost:${dashboardPort}`; + +const globalState = globalThis as typeof globalThis & { + __omnirouteModelSyncInternalAuthToken?: string; +}; let schedulerTimer: NodeJS.Timeout | null = null; let isRunning = false; +let internalAuthToken: string | null = null; + +function getInternalAuthToken(): string { + if (!internalAuthToken) { + internalAuthToken = globalState.__omnirouteModelSyncInternalAuthToken || randomUUID(); + globalState.__omnirouteModelSyncInternalAuthToken = internalAuthToken; + } + return internalAuthToken; +} + +export function getModelSyncInternalAuthHeaderName(): string { + return MODEL_SYNC_INTERNAL_AUTH_HEADER; +} + +export function buildModelSyncInternalHeaders(): Record { + return { [MODEL_SYNC_INTERNAL_AUTH_HEADER]: getInternalAuthToken() }; +} + +export function isModelSyncInternalRequest(request: Request): boolean { + if (!internalAuthToken && globalState.__omnirouteModelSyncInternalAuthToken) { + internalAuthToken = globalState.__omnirouteModelSyncInternalAuthToken; + } + const headerToken = request.headers.get(MODEL_SYNC_INTERNAL_AUTH_HEADER); + return Boolean(headerToken && internalAuthToken && headerToken === internalAuthToken); +} /** * Fetch all provider connections that have autoSync enabled. @@ -50,7 +90,10 @@ async function syncConnectionModels( try { const res = await fetch(`${baseUrl}/api/providers/${connectionId}/sync-models`, { method: "POST", - headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" }, + headers: { + "Content-Type": "application/json", + ...buildModelSyncInternalHeaders(), + }, }); if (!res.ok) { console.warn( @@ -121,7 +164,7 @@ async function runSyncCycle(apiBaseUrl: string): Promise { * @param intervalMs — sync interval in milliseconds (default: 24h) */ export function startModelSyncScheduler( - apiBaseUrl = "http://localhost:20128", + apiBaseUrl = INTERNAL_BASE_URL, intervalMs = DEFAULT_INTERVAL_MS ): void { if (schedulerTimer) { diff --git a/tests/unit/model-sync-scheduler.test.mjs b/tests/unit/model-sync-scheduler.test.mjs new file mode 100644 index 0000000000..f9fc143b44 --- /dev/null +++ b/tests/unit/model-sync-scheduler.test.mjs @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +test("modelSyncScheduler: internal auth headers validate only for scheduler requests", async () => { + const { + buildModelSyncInternalHeaders, + getModelSyncInternalAuthHeaderName, + isModelSyncInternalRequest, + } = await import("../../src/shared/services/modelSyncScheduler.ts"); + + const internalRequest = new Request("http://localhost/api/providers/test/sync-models", { + method: "POST", + headers: buildModelSyncInternalHeaders(), + }); + assert.equal(isModelSyncInternalRequest(internalRequest), true); + + const externalRequest = new Request("http://localhost/api/providers/test/sync-models", { + method: "POST", + headers: { [getModelSyncInternalAuthHeaderName()]: "invalid-token" }, + }); + assert.equal(isModelSyncInternalRequest(externalRequest), false); +}); + +test("initCloudSync: startup initialization also starts model sync scheduler", () => { + const filePath = path.join(process.cwd(), "src/lib/initCloudSync.ts"); + const source = fs.readFileSync(filePath, "utf8"); + + assert.match(source, /startModelSyncScheduler\s*\(/); +}); + +test("proxy: internal model sync token is only allowed for provider model sync routes", () => { + const filePath = path.join(process.cwd(), "src/proxy.ts"); + const source = fs.readFileSync(filePath, "utf8"); + + assert.match(source, /isModelSyncInternalRequest/); + assert.match(source, /sync-models\|models/); +});