475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { wildcardMatch } from "./wildcardRouter.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
export type PayloadRuleModelSpec = {
|
|
name: string;
|
|
protocol?: string;
|
|
};
|
|
|
|
export type PayloadMutationRule = {
|
|
models: PayloadRuleModelSpec[];
|
|
params: Record<string, unknown>;
|
|
};
|
|
|
|
export type PayloadFilterRule = {
|
|
models: PayloadRuleModelSpec[];
|
|
params: string[];
|
|
};
|
|
|
|
export type PayloadRulesConfig = {
|
|
default: PayloadMutationRule[];
|
|
override: PayloadMutationRule[];
|
|
filter: PayloadFilterRule[];
|
|
defaultRaw: PayloadMutationRule[];
|
|
};
|
|
|
|
export type AppliedPayloadRule = {
|
|
type: "default" | "override" | "filter" | "default-raw";
|
|
path: string;
|
|
value?: unknown;
|
|
};
|
|
|
|
const DEFAULT_PAYLOAD_RULES_CONFIG: PayloadRulesConfig = {
|
|
default: [],
|
|
override: [],
|
|
filter: [],
|
|
defaultRaw: [],
|
|
};
|
|
|
|
const MIN_FILE_CHECK_INTERVAL_MS = 1_000;
|
|
const DEFAULT_FILE_CHECK_INTERVAL_MS = 5_000;
|
|
|
|
let runtimeOverride: PayloadRulesConfig | null = null;
|
|
let cachedFileConfig = clonePayloadRulesConfig(DEFAULT_PAYLOAD_RULES_CONFIG);
|
|
let cachedFilePath = "";
|
|
let cachedFileMtimeMs = -1;
|
|
let lastFileCheckAt = 0;
|
|
let fileLoadPromise: Promise<void> | null = null;
|
|
let lastFileErrorSignature = "";
|
|
|
|
function toRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function toArray<T>(value: unknown): T[] {
|
|
return Array.isArray(value) ? (value as T[]) : [];
|
|
}
|
|
|
|
function cloneValue<T>(value: T): T {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function clonePayloadRulesConfig(config: PayloadRulesConfig): PayloadRulesConfig {
|
|
return {
|
|
default: config.default.map((rule) => ({
|
|
models: rule.models.map((model) => ({ ...model })),
|
|
params: cloneValue(rule.params),
|
|
})),
|
|
override: config.override.map((rule) => ({
|
|
models: rule.models.map((model) => ({ ...model })),
|
|
params: cloneValue(rule.params),
|
|
})),
|
|
filter: config.filter.map((rule) => ({
|
|
models: rule.models.map((model) => ({ ...model })),
|
|
params: [...rule.params],
|
|
})),
|
|
defaultRaw: config.defaultRaw.map((rule) => ({
|
|
models: rule.models.map((model) => ({ ...model })),
|
|
params: cloneValue(rule.params),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function normalizeModelSpecs(value: unknown): PayloadRuleModelSpec[] {
|
|
return toArray<JsonRecord>(value)
|
|
.map((item) => {
|
|
const name = typeof item?.name === "string" ? item.name.trim() : "";
|
|
const protocol = typeof item?.protocol === "string" ? item.protocol.trim() : "";
|
|
if (!name) return null;
|
|
return protocol ? { name, protocol } : { name };
|
|
})
|
|
.filter((item): item is PayloadRuleModelSpec => !!item);
|
|
}
|
|
|
|
function normalizeMutationRules(value: unknown): PayloadMutationRule[] {
|
|
return toArray<JsonRecord>(value)
|
|
.map((item) => {
|
|
const models = normalizeModelSpecs(item?.models);
|
|
const params = toRecord(item?.params);
|
|
if (models.length === 0 || Object.keys(params).length === 0) return null;
|
|
return { models, params };
|
|
})
|
|
.filter((item): item is PayloadMutationRule => !!item);
|
|
}
|
|
|
|
function normalizeFilterRules(value: unknown): PayloadFilterRule[] {
|
|
return toArray<JsonRecord>(value)
|
|
.map((item) => {
|
|
const models = normalizeModelSpecs(item?.models);
|
|
const params = toArray<unknown>(item?.params)
|
|
.map((pathValue) => (typeof pathValue === "string" ? pathValue.trim() : ""))
|
|
.filter(Boolean);
|
|
if (models.length === 0 || params.length === 0) return null;
|
|
return { models, params };
|
|
})
|
|
.filter((item): item is PayloadFilterRule => !!item);
|
|
}
|
|
|
|
export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig {
|
|
const record = toRecord(value);
|
|
const defaultRawLegacy = toArray<JsonRecord>(record["default-raw"]);
|
|
const defaultRaw = [...toArray<JsonRecord>(record.defaultRaw), ...defaultRawLegacy];
|
|
|
|
return {
|
|
default: normalizeMutationRules(record.default),
|
|
override: normalizeMutationRules(record.override),
|
|
filter: normalizeFilterRules(record.filter),
|
|
defaultRaw: normalizeMutationRules(defaultRaw),
|
|
};
|
|
}
|
|
|
|
function getPayloadRulesPath() {
|
|
return (
|
|
process.env.OMNIROUTE_PAYLOAD_RULES_PATH ||
|
|
process.env.PAYLOAD_RULES_PATH ||
|
|
path.join(/* turbopackIgnore: true */ process.cwd(), "config", "payloadRules.json")
|
|
);
|
|
}
|
|
|
|
function getPayloadRulesReloadIntervalMs() {
|
|
const parsed = Number.parseInt(process.env.OMNIROUTE_PAYLOAD_RULES_RELOAD_MS || "", 10);
|
|
if (!Number.isFinite(parsed) || parsed < MIN_FILE_CHECK_INTERVAL_MS) {
|
|
return DEFAULT_FILE_CHECK_INTERVAL_MS;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function clearCachedFileConfig() {
|
|
cachedFileConfig = clonePayloadRulesConfig(DEFAULT_PAYLOAD_RULES_CONFIG);
|
|
cachedFileMtimeMs = -1;
|
|
}
|
|
|
|
async function refreshPayloadRulesFileCache(force = false) {
|
|
const filePath = getPayloadRulesPath();
|
|
const now = Date.now();
|
|
|
|
if (
|
|
!force &&
|
|
filePath === cachedFilePath &&
|
|
now - lastFileCheckAt < getPayloadRulesReloadIntervalMs()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (fileLoadPromise) {
|
|
await fileLoadPromise;
|
|
return;
|
|
}
|
|
|
|
fileLoadPromise = (async () => {
|
|
lastFileCheckAt = now;
|
|
cachedFilePath = filePath;
|
|
|
|
try {
|
|
const stat = await fs.stat(filePath);
|
|
if (!force && cachedFileMtimeMs === stat.mtimeMs) {
|
|
return;
|
|
}
|
|
|
|
const content = await fs.readFile(filePath, "utf-8");
|
|
const parsed = JSON.parse(content);
|
|
cachedFileConfig = normalizePayloadRulesConfig(parsed);
|
|
cachedFileMtimeMs = stat.mtimeMs;
|
|
lastFileErrorSignature = "";
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
clearCachedFileConfig();
|
|
lastFileErrorSignature = "";
|
|
return;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const errorSignature = `${filePath}:${message}`;
|
|
if (errorSignature !== lastFileErrorSignature) {
|
|
console.warn(`[PAYLOAD_RULES] Failed to load ${filePath}: ${message}`);
|
|
lastFileErrorSignature = errorSignature;
|
|
}
|
|
}
|
|
})();
|
|
|
|
try {
|
|
await fileLoadPromise;
|
|
} finally {
|
|
fileLoadPromise = null;
|
|
}
|
|
}
|
|
|
|
export function setPayloadRulesConfig(config: unknown) {
|
|
runtimeOverride = normalizePayloadRulesConfig(config);
|
|
}
|
|
|
|
export function clearPayloadRulesConfigOverride() {
|
|
runtimeOverride = null;
|
|
}
|
|
|
|
// #2986: Read the DB-persisted payload rules (the source of truth, written by
|
|
// the Settings UI via updateSettings). Used as the fallback when no in-memory
|
|
// runtimeOverride is set — e.g. a fresh process before the startup
|
|
// applyRuntimeSettings hook ran, or a separate module instance in the
|
|
// standalone Next.js build — so saved rules survive a server restart instead of
|
|
// silently reverting to the (usually empty) file config.
|
|
async function loadPayloadRulesFromSettings(): Promise<PayloadRulesConfig | null> {
|
|
try {
|
|
const { getCachedSettings } = await import("@/lib/localDb");
|
|
const settings = (await getCachedSettings()) as { payloadRules?: unknown };
|
|
const raw = settings?.payloadRules;
|
|
if (raw === null || raw === undefined) return null;
|
|
return normalizePayloadRulesConfig(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getPayloadRulesConfig(options: { forceRefresh?: boolean } = {}) {
|
|
if (runtimeOverride) {
|
|
return clonePayloadRulesConfig(runtimeOverride);
|
|
}
|
|
|
|
// #2986: prefer the DB-persisted rules over the file config so a saved
|
|
// configuration survives a restart even when the in-memory override is absent.
|
|
const dbConfig = await loadPayloadRulesFromSettings();
|
|
if (dbConfig) {
|
|
return clonePayloadRulesConfig(dbConfig);
|
|
}
|
|
|
|
await refreshPayloadRulesFileCache(options.forceRefresh === true);
|
|
return clonePayloadRulesConfig(cachedFileConfig);
|
|
}
|
|
|
|
function getPathSegments(pathValue: string) {
|
|
return pathValue
|
|
.split(".")
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isIndexSegment(segment: string) {
|
|
return /^\d+$/.test(segment);
|
|
}
|
|
|
|
function getValueAtPath(payload: unknown, pathValue: string) {
|
|
const segments = getPathSegments(pathValue);
|
|
let cursor: unknown = payload;
|
|
|
|
for (const segment of segments) {
|
|
if (cursor == null) return undefined;
|
|
if (Array.isArray(cursor)) {
|
|
if (!isIndexSegment(segment)) return undefined;
|
|
cursor = cursor[Number(segment)];
|
|
continue;
|
|
}
|
|
if (typeof cursor !== "object") return undefined;
|
|
cursor = (cursor as JsonRecord)[segment];
|
|
}
|
|
|
|
return cursor;
|
|
}
|
|
|
|
function setValueAtPath(payload: JsonRecord, pathValue: string, value: unknown) {
|
|
const segments = getPathSegments(pathValue);
|
|
if (segments.length === 0) return;
|
|
|
|
let cursor: unknown = payload;
|
|
for (let index = 0; index < segments.length - 1; index++) {
|
|
const segment = segments[index];
|
|
const nextSegment = segments[index + 1];
|
|
const nextIsIndex = isIndexSegment(nextSegment);
|
|
|
|
if (Array.isArray(cursor)) {
|
|
const arrayIndex = Number(segment);
|
|
if (!Number.isInteger(arrayIndex)) return;
|
|
if (cursor[arrayIndex] == null || typeof cursor[arrayIndex] !== "object") {
|
|
cursor[arrayIndex] = nextIsIndex ? [] : {};
|
|
}
|
|
cursor = cursor[arrayIndex];
|
|
continue;
|
|
}
|
|
|
|
if (!cursor || typeof cursor !== "object") return;
|
|
|
|
const recordCursor = cursor as JsonRecord;
|
|
if (
|
|
recordCursor[segment] == null ||
|
|
typeof recordCursor[segment] !== "object" ||
|
|
(Array.isArray(recordCursor[segment]) && !nextIsIndex) ||
|
|
(!Array.isArray(recordCursor[segment]) && nextIsIndex)
|
|
) {
|
|
recordCursor[segment] = nextIsIndex ? [] : {};
|
|
}
|
|
|
|
cursor = recordCursor[segment];
|
|
}
|
|
|
|
const lastSegment = segments.at(-1)!;
|
|
if (Array.isArray(cursor)) {
|
|
const arrayIndex = Number(lastSegment);
|
|
if (!Number.isInteger(arrayIndex)) return;
|
|
cursor[arrayIndex] = cloneValue(value);
|
|
return;
|
|
}
|
|
|
|
if (!cursor || typeof cursor !== "object") return;
|
|
(cursor as JsonRecord)[lastSegment] = cloneValue(value);
|
|
}
|
|
|
|
function unsetValueAtPath(payload: JsonRecord, pathValue: string) {
|
|
const segments = getPathSegments(pathValue);
|
|
if (segments.length === 0) return false;
|
|
|
|
let cursor: unknown = payload;
|
|
for (let index = 0; index < segments.length - 1; index++) {
|
|
const segment = segments[index];
|
|
if (Array.isArray(cursor)) {
|
|
if (!isIndexSegment(segment)) return false;
|
|
cursor = cursor[Number(segment)];
|
|
continue;
|
|
}
|
|
if (!cursor || typeof cursor !== "object") return false;
|
|
cursor = (cursor as JsonRecord)[segment];
|
|
}
|
|
|
|
const lastSegment = segments.at(-1)!;
|
|
if (Array.isArray(cursor)) {
|
|
if (!isIndexSegment(lastSegment)) return false;
|
|
const arrayIndex = Number(lastSegment);
|
|
if (arrayIndex < 0 || arrayIndex >= cursor.length) return false;
|
|
cursor.splice(arrayIndex, 1);
|
|
return true;
|
|
}
|
|
|
|
if (!cursor || typeof cursor !== "object") return false;
|
|
if (!Object.hasOwn(cursor, lastSegment)) return false;
|
|
delete (cursor as JsonRecord)[lastSegment];
|
|
return true;
|
|
}
|
|
|
|
function parseDefaultRawValue(value: unknown) {
|
|
if (typeof value !== "string") return value;
|
|
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function matchesProtocol(specProtocol: string | undefined, protocols: string[]) {
|
|
if (!specProtocol) return true;
|
|
const normalizedProtocol = specProtocol.trim().toLowerCase();
|
|
return protocols.some((protocol) => protocol.trim().toLowerCase() === normalizedProtocol);
|
|
}
|
|
|
|
function matchesModelSpec(model: string, protocols: string[], spec: PayloadRuleModelSpec) {
|
|
return matchesProtocol(spec.protocol, protocols) && wildcardMatch(model, spec.name);
|
|
}
|
|
|
|
function matchesRule(model: string, protocols: string[], specs: PayloadRuleModelSpec[]) {
|
|
return specs.some((spec) => matchesModelSpec(model, protocols, spec));
|
|
}
|
|
|
|
function toPayloadRuleProtocols(value: string | string[]) {
|
|
const protocols = Array.isArray(value) ? value : [value];
|
|
return [...new Set(protocols.map((protocol) => protocol.trim()).filter(Boolean))];
|
|
}
|
|
|
|
export function resolvePayloadRuleProtocols({
|
|
provider,
|
|
targetFormat,
|
|
}: {
|
|
provider?: string | null;
|
|
targetFormat?: string | null;
|
|
}) {
|
|
const protocols = new Set<string>();
|
|
|
|
if (provider) protocols.add(provider);
|
|
if (targetFormat) protocols.add(targetFormat);
|
|
if (targetFormat === "openai-responses" || targetFormat === "openai-response") {
|
|
protocols.add("openai");
|
|
}
|
|
if (targetFormat === "antigravity") {
|
|
protocols.add("gemini");
|
|
}
|
|
|
|
return [...protocols];
|
|
}
|
|
|
|
export function applyPayloadRules(
|
|
payload: JsonRecord,
|
|
model: string,
|
|
protocol: string | string[],
|
|
rules: PayloadRulesConfig
|
|
) {
|
|
const normalizedPayload = cloneValue(payload);
|
|
const protocols = toPayloadRuleProtocols(protocol);
|
|
const applied: AppliedPayloadRule[] = [];
|
|
|
|
for (const rule of rules.default) {
|
|
if (!matchesRule(model, protocols, rule.models)) continue;
|
|
for (const [pathValue, rawValue] of Object.entries(rule.params)) {
|
|
if (getValueAtPath(normalizedPayload, pathValue) !== undefined) continue;
|
|
setValueAtPath(normalizedPayload, pathValue, rawValue);
|
|
applied.push({ type: "default", path: pathValue, value: cloneValue(rawValue) });
|
|
}
|
|
}
|
|
|
|
for (const rule of rules.defaultRaw) {
|
|
if (!matchesRule(model, protocols, rule.models)) continue;
|
|
for (const [pathValue, rawValue] of Object.entries(rule.params)) {
|
|
if (getValueAtPath(normalizedPayload, pathValue) !== undefined) continue;
|
|
const parsedValue = parseDefaultRawValue(rawValue);
|
|
setValueAtPath(normalizedPayload, pathValue, parsedValue);
|
|
applied.push({ type: "default-raw", path: pathValue, value: cloneValue(parsedValue) });
|
|
}
|
|
}
|
|
|
|
for (const rule of rules.override) {
|
|
if (!matchesRule(model, protocols, rule.models)) continue;
|
|
for (const [pathValue, rawValue] of Object.entries(rule.params)) {
|
|
setValueAtPath(normalizedPayload, pathValue, rawValue);
|
|
applied.push({ type: "override", path: pathValue, value: cloneValue(rawValue) });
|
|
}
|
|
}
|
|
|
|
for (const rule of rules.filter) {
|
|
if (!matchesRule(model, protocols, rule.models)) continue;
|
|
for (const pathValue of rule.params) {
|
|
if (!unsetValueAtPath(normalizedPayload, pathValue)) continue;
|
|
applied.push({ type: "filter", path: pathValue });
|
|
}
|
|
}
|
|
|
|
return { payload: normalizedPayload, applied };
|
|
}
|
|
|
|
export async function applyConfiguredPayloadRules(
|
|
payload: JsonRecord,
|
|
model: string,
|
|
protocol: string | string[]
|
|
) {
|
|
const rules = await getPayloadRulesConfig();
|
|
return applyPayloadRules(payload, model, protocol, rules);
|
|
}
|
|
|
|
export function resetPayloadRulesConfigForTests() {
|
|
runtimeOverride = null;
|
|
cachedFilePath = "";
|
|
cachedFileMtimeMs = -1;
|
|
lastFileCheckAt = 0;
|
|
fileLoadPromise = null;
|
|
lastFileErrorSignature = "";
|
|
cachedFileConfig = clonePayloadRulesConfig(DEFAULT_PAYLOAD_RULES_CONFIG);
|
|
}
|