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>} */ 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 };