fix(model-sync): log only channel-level model changes

This commit is contained in:
R.D.
2026-03-31 00:44:38 -04:00
parent e071393eb5
commit 715101cf5e
2 changed files with 240 additions and 21 deletions

View File

@@ -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<string, unknown>;
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) {

View File

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