337 lines
9.7 KiB
JavaScript
337 lines
9.7 KiB
JavaScript
const { Prisma } = require("@prisma/client");
|
|
const prisma = require("../utils/prisma");
|
|
const { safeJsonParse } = require("../utils/http");
|
|
|
|
const VALID_TYPES = ["calculated", "llm"];
|
|
const VALID_PROPERTIES = [
|
|
"promptContent",
|
|
"conversationTokenCount",
|
|
"conversationMessageCount",
|
|
"currentHour",
|
|
"hasImageAttachment",
|
|
];
|
|
const VALID_COMPARATORS = [
|
|
"contains",
|
|
"matches",
|
|
"gt",
|
|
"gte",
|
|
"lt",
|
|
"lte",
|
|
"eq",
|
|
"neq",
|
|
"between",
|
|
];
|
|
const VALID_CONDITION_LOGIC = ["AND"];
|
|
const TITLE_REGEX = /^[a-z0-9_]+$/;
|
|
|
|
const ModelRouterRule = {
|
|
VALID_TYPES,
|
|
VALID_PROPERTIES,
|
|
VALID_COMPARATORS,
|
|
VALID_CONDITION_LOGIC,
|
|
|
|
create: async function (routerId, data = {}, creatorId = null) {
|
|
if (!routerId) return { rule: null, error: "Router ID is required." };
|
|
|
|
const title = this._validateTitle(data.title);
|
|
if (!title)
|
|
return {
|
|
rule: null,
|
|
error: "Title is required and must be lowercase with underscores only.",
|
|
};
|
|
|
|
const type = VALID_TYPES.includes(data.type) ? data.type : null;
|
|
if (!type)
|
|
return {
|
|
rule: null,
|
|
error: `Type must be one of: ${VALID_TYPES.join(", ")}`,
|
|
};
|
|
|
|
let conditionLogic = null;
|
|
let serializedConditions = null;
|
|
|
|
if (type === "calculated") {
|
|
const validated = this._validateConditions(
|
|
data.condition_logic,
|
|
data.conditions
|
|
);
|
|
if (validated.error) return { rule: null, error: validated.error };
|
|
conditionLogic = validated.condition_logic;
|
|
serializedConditions = validated.serialized;
|
|
} else if (type === "llm") {
|
|
if (!data.description || !data.description.trim())
|
|
return {
|
|
rule: null,
|
|
error:
|
|
"Description is required for LLM rules. Describe when this rule should match.",
|
|
};
|
|
}
|
|
|
|
if (!data.route_provider || !data.route_model)
|
|
return {
|
|
rule: null,
|
|
error: "Route provider and model are required.",
|
|
};
|
|
|
|
try {
|
|
const rule = await prisma.model_router_rules.create({
|
|
data: {
|
|
router_id: Number(routerId),
|
|
enabled: true, // new rules are always enabled
|
|
priority: Number(data.priority),
|
|
type,
|
|
title,
|
|
description: data.description || null,
|
|
condition_logic: conditionLogic,
|
|
conditions: serializedConditions,
|
|
route_provider: String(data.route_provider),
|
|
route_model: String(data.route_model),
|
|
created_by: creatorId ? Number(creatorId) : null,
|
|
},
|
|
});
|
|
return { rule: this._hydrate(rule), error: null };
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
return (
|
|
handleUniqueConstraintError(error) || {
|
|
rule: null,
|
|
error: error.message,
|
|
}
|
|
);
|
|
}
|
|
},
|
|
|
|
get: async function (clause = {}) {
|
|
try {
|
|
const rule = await prisma.model_router_rules.findFirst({
|
|
where: clause,
|
|
});
|
|
return this._hydrate(rule);
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
where: async function (clause = {}, limit = null, orderBy = null) {
|
|
try {
|
|
const results = await prisma.model_router_rules.findMany({
|
|
where: clause,
|
|
...(limit !== null ? { take: limit } : {}),
|
|
...(orderBy !== null ? { orderBy } : {}),
|
|
});
|
|
return results.map((r) => this._hydrate(r));
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
return [];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get all rules for a given router, ordered by priority.
|
|
* -> delegate to the where method
|
|
* @param {number} routerId
|
|
* @returns {Promise<Array<{id: number, title: string, description: string, enabled: boolean, priority: number, type: string, route_provider: string, route_model: string, created_by: number, createdAt: Date, lastUpdatedAt: Date}>>}
|
|
*/
|
|
forRouter: async function (routerId) {
|
|
return this.where({ router_id: Number(routerId) }, null, {
|
|
priority: "asc",
|
|
});
|
|
},
|
|
|
|
update: async function (id = null, data = {}) {
|
|
if (!id) throw new Error("No rule id provided for update");
|
|
|
|
const updates = {};
|
|
|
|
if (data.title !== undefined) {
|
|
const title = this._validateTitle(data.title);
|
|
if (!title)
|
|
return {
|
|
rule: null,
|
|
error: "Title must be lowercase with underscores only.",
|
|
};
|
|
updates.title = title;
|
|
}
|
|
|
|
const simpleFields = [
|
|
["enabled", (v) => Boolean(v)],
|
|
["priority", (v) => Number(v)],
|
|
["description", (v) => v || null],
|
|
["route_provider", (v) => String(v)],
|
|
["route_model", (v) => String(v)],
|
|
];
|
|
for (const [key, map] of simpleFields) {
|
|
if (data[key] !== undefined) updates[key] = map(data[key]);
|
|
}
|
|
|
|
const typeErr = assignEnum(updates, data, "type", VALID_TYPES, "Type");
|
|
if (typeErr) return typeErr;
|
|
|
|
// Conditions are only meaningful for calculated rules. When either
|
|
// condition_logic or conditions is provided we revalidate the pair
|
|
// together so we never persist a half-updated rule.
|
|
if (data.condition_logic !== undefined || data.conditions !== undefined) {
|
|
const effectiveType = updates.type || data.type;
|
|
if (effectiveType === "llm") {
|
|
updates.condition_logic = null;
|
|
updates.conditions = null;
|
|
} else {
|
|
const validated = this._validateConditions(
|
|
data.condition_logic,
|
|
data.conditions
|
|
);
|
|
if (validated.error) return { rule: null, error: validated.error };
|
|
updates.condition_logic = validated.condition_logic;
|
|
updates.conditions = validated.serialized;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0)
|
|
return { rule: { id }, error: "No valid fields to update." };
|
|
|
|
try {
|
|
const rule = await prisma.model_router_rules.update({
|
|
where: { id: Number(id) },
|
|
data: updates,
|
|
});
|
|
return { rule: this._hydrate(rule), error: null };
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
return (
|
|
handleUniqueConstraintError(error) || {
|
|
rule: null,
|
|
error: error.message,
|
|
}
|
|
);
|
|
}
|
|
},
|
|
|
|
delete: async function (id = null) {
|
|
if (!id) return false;
|
|
try {
|
|
await prisma.model_router_rules.delete({
|
|
where: { id: Number(id) },
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Bulk update priorities for rules within a router.
|
|
* @param {Array<{id: number, priority: number}>} ruleUpdates
|
|
*/
|
|
reorderRules: async function (ruleUpdates = []) {
|
|
try {
|
|
await prisma.$transaction(
|
|
ruleUpdates.map(({ id, priority }) =>
|
|
prisma.model_router_rules.update({
|
|
where: { id: Number(id) },
|
|
data: { priority: Number(priority) },
|
|
})
|
|
)
|
|
);
|
|
return { success: true, error: null };
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
return { success: false, error: error.message };
|
|
}
|
|
},
|
|
|
|
_validateTitle: function (title) {
|
|
if (!title || typeof title !== "string") return null;
|
|
const cleaned = title.trim().toLowerCase();
|
|
if (!TITLE_REGEX.test(cleaned)) return null;
|
|
return cleaned;
|
|
},
|
|
|
|
/**
|
|
* Validate a (condition_logic, conditions[]) pair for calculated rules.
|
|
* Returns either `{ error }` or `{ condition_logic, serialized }`.
|
|
*/
|
|
_validateConditions: function (logic, conditions) {
|
|
if (!VALID_CONDITION_LOGIC.includes(logic))
|
|
return {
|
|
error: `Condition logic must be one of: ${VALID_CONDITION_LOGIC.join(", ")}`,
|
|
};
|
|
if (!Array.isArray(conditions) || conditions.length === 0)
|
|
return { error: "At least one condition is required." };
|
|
|
|
const normalized = [];
|
|
for (const [i, c] of conditions.entries()) {
|
|
const cond = c || {};
|
|
if (!VALID_PROPERTIES.includes(cond.property))
|
|
return {
|
|
error: `Condition ${i + 1}: property must be one of: ${VALID_PROPERTIES.join(", ")}`,
|
|
};
|
|
if (!VALID_COMPARATORS.includes(cond.comparator))
|
|
return {
|
|
error: `Condition ${i + 1}: comparator must be one of: ${VALID_COMPARATORS.join(", ")}`,
|
|
};
|
|
if (
|
|
cond.value === undefined ||
|
|
cond.value === null ||
|
|
String(cond.value) === ""
|
|
)
|
|
return { error: `Condition ${i + 1}: value is required.` };
|
|
normalized.push({
|
|
property: cond.property,
|
|
comparator: cond.comparator,
|
|
value: String(cond.value),
|
|
});
|
|
}
|
|
|
|
return {
|
|
condition_logic: logic,
|
|
serialized: JSON.stringify(normalized),
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Parse the stored JSON `conditions` string into an array so every consumer
|
|
* (API responses, evaluator, UI) works with the same shape. Safe on null and
|
|
* on rule types that don't use conditions.
|
|
*/
|
|
_hydrate: function (rule) {
|
|
if (!rule) return null;
|
|
return { ...rule, conditions: safeJsonParse(rule.conditions, null) };
|
|
},
|
|
};
|
|
|
|
function assignEnum(updates, data, key, validList, label) {
|
|
if (data[key] === undefined) return null;
|
|
if (!validList.includes(data[key]))
|
|
return {
|
|
rule: null,
|
|
error: `${label} must be one of: ${validList.join(", ")}`,
|
|
};
|
|
updates[key] = data[key];
|
|
return null;
|
|
}
|
|
|
|
function handleUniqueConstraintError(error) {
|
|
if (
|
|
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
|
|
error.code !== "P2002"
|
|
)
|
|
return null;
|
|
|
|
const target = error.meta?.target;
|
|
if (target?.includes("title"))
|
|
return {
|
|
rule: null,
|
|
error: "A rule with that title already exists on this router.",
|
|
};
|
|
if (target?.includes("priority"))
|
|
return {
|
|
rule: null,
|
|
error: "A rule with that priority already exists on this router.",
|
|
};
|
|
return { rule: null, error: "Duplicate rule constraint violated." };
|
|
}
|
|
|
|
module.exports = { ModelRouterRule };
|