diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 7b6d8b858e..581cc6a73e 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; -import { replaceCustomModels } from "@/lib/db/models"; +import { getCustomModels, replaceCustomModels } from "@/lib/db/models"; import { syncManagedAvailableModelAliases, usesManagedAvailableModels, @@ -13,11 +13,108 @@ import { } from "@/shared/services/modelSyncScheduler"; import { getModelsByProviderId } from "@/shared/constants/models"; +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeModelForComparison(model: unknown) { + const record = asRecord(model); + const id = toNonEmptyString(record.id) || ""; + const name = toNonEmptyString(record.name) || id; + const source = toNonEmptyString(record.source) || "auto-sync"; + const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions"; + const supportedEndpoints = Array.isArray(record.supportedEndpoints) + ? Array.from( + new Set( + record.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : ["chat"]; + + return { + id, + name, + source, + apiFormat, + supportedEndpoints, + }; +} + +function summarizeModelChanges(previousModels: unknown, nextModels: unknown) { + const previousList = Array.isArray(previousModels) ? previousModels : []; + const nextList = Array.isArray(nextModels) ? nextModels : []; + + const previousMap = new Map( + previousList + .map((model) => normalizeModelForComparison(model)) + .filter((model) => model.id) + .map((model) => [model.id, JSON.stringify(model)]) + ); + const nextMap = new Map( + nextList + .map((model) => normalizeModelForComparison(model)) + .filter((model) => model.id) + .map((model) => [model.id, JSON.stringify(model)]) + ); + + let added = 0; + let removed = 0; + let updated = 0; + + for (const [id, nextValue] of nextMap.entries()) { + const previousValue = previousMap.get(id); + if (!previousValue) { + added += 1; + continue; + } + if (previousValue !== nextValue) { + updated += 1; + } + } + + for (const id of previousMap.keys()) { + if (!nextMap.has(id)) { + removed += 1; + } + } + + return { + added, + removed, + updated, + total: added + removed + updated, + }; +} + +function getModelSyncChannelLabel(connection: unknown) { + const record = asRecord(connection); + const providerSpecificData = asRecord(record.providerSpecificData); + + return ( + toNonEmptyString(record.displayName) || + toNonEmptyString(record.email) || + toNonEmptyString(providerSpecificData.tag) || + toNonEmptyString(record.name) || + toNonEmptyString(record.provider) || + (toNonEmptyString(record.id) ? `connection:${String(record.id).slice(0, 8)}` : null) || + "unknown" + ); +} + /** * 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. + * full custom models list for that provider. Successful syncs only write a + * call log when the fetched channel actually changes the stored model list. * * Used by: * - modelSyncScheduler (auto-sync on interval) @@ -40,8 +137,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } - // Use a human-readable provider name for logs - const providerLabel = connection.name || connection.provider || "unknown"; + const providerLabel = getModelSyncChannelLabel(connection); // Fetch models from the existing /api/providers/[id]/models endpoint const origin = new URL(request.url).origin; @@ -92,7 +188,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: })) .filter((m: any) => m.id && !registryIds.has(m.id)); + const previousModels = await getCustomModels(connection.provider); const replaced = await replaceCustomModels(connection.provider, models); + const modelChanges = summarizeModelChanges(previousModels, replaced); let syncedAliases = 0; if (usesManagedAvailableModels(connection.provider)) { @@ -103,29 +201,34 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: syncedAliases = aliasSync.assignedAliases.length; } - // Log the successful sync - await saveCallLog({ - method: "GET", - path: `/api/providers/${id}/models`, - status: 200, - model: "model-sync", - provider: providerLabel, - sourceFormat: "-", - connectionId: id, - duration: Date.now() - start, - requestType: "model-sync", - responseBody: { - syncedModels: models.length, - syncedAliases, - provider: connection.provider, - }, - }); + if (modelChanges.total > 0) { + await saveCallLog({ + method: "GET", + path: `/api/providers/${id}/models`, + status: 200, + model: "model-sync", + provider: providerLabel, + sourceFormat: "-", + connectionId: id, + duration: Date.now() - start, + requestType: "model-sync", + responseBody: { + syncedModels: models.length, + syncedAliases, + provider: connection.provider, + channel: providerLabel, + modelChanges, + }, + }); + } return NextResponse.json({ ok: true, provider: connection.provider, syncedModels: replaced.length, syncedAliases, + modelChanges, + logged: modelChanges.total > 0, models: replaced, }); } catch (error: any) { diff --git a/tests/unit/model-sync-route.test.mjs b/tests/unit/model-sync-route.test.mjs new file mode 100644 index 0000000000..5d1d9e7e18 --- /dev/null +++ b/tests/unit/model-sync-route.test.mjs @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-sync-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); +const modelSyncRoute = await import("../../src/app/api/providers/[id]/sync-models/route.ts"); +const scheduler = await import("../../src/shared/services/modelSyncScheduler.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("model sync route skips success log when fetched models do not change stored models", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "MAIN", + displayName: "channel-east", + apiKey: "test-key", + }); + + await modelsDb.replaceCustomModels("openai", [ + { + id: "custom-model-1", + name: "Custom Model 1", + source: "auto-sync", + }, + ]); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + return Response.json({ + models: [{ id: "custom-model-1", name: "Custom Model 1" }], + }); + }; + + try { + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.logged, false); + assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); + + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + assert.equal(logs.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("model sync route logs changed model syncs against the current channel label", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "MAIN", + displayName: "channel-west", + apiKey: "test-key", + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + return Response.json({ + models: [{ id: "custom-model-2", name: "Custom Model 2" }], + }); + }; + + try { + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.logged, true); + assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); + + const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); + assert.equal(logs.length, 1); + assert.equal(logs[0].provider, "channel-west"); + assert.equal(logs[0].model, "model-sync"); + } finally { + globalThis.fetch = originalFetch; + } +});