From fb597f33c22e5482defdb7ada504308817a1de86 Mon Sep 17 00:00:00 2001 From: "Blessing Jones, Joshua Divine" Date: Tue, 11 Aug 2026 12:02:06 +0530 Subject: [PATCH 1/3] feat: add ModelRouterEngine with keyword/llm/weighted routing - New ModelRouterEngine that routes LLM queries to backing engines - Three modes: keyword, llm (LLM classifier), weighted (round-robin) - Config loaded from router.json in engine assets folder (ROUTER_CONFIG SMSS key) - Registered MODEL_ROUTER in ModelTypeEnum --- src/prerna/engine/api/ModelTypeEnum.java | 4 + .../engine/impl/model/ModelRouterEngine.java | 477 ++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 src/prerna/engine/impl/model/ModelRouterEngine.java diff --git a/src/prerna/engine/api/ModelTypeEnum.java b/src/prerna/engine/api/ModelTypeEnum.java index eac1a6c841d..ce422886c3b 100644 --- a/src/prerna/engine/api/ModelTypeEnum.java +++ b/src/prerna/engine/api/ModelTypeEnum.java @@ -35,6 +35,7 @@ import prerna.engine.impl.model.KServeImageEngine; import prerna.engine.impl.model.KServeTTSEngine; import prerna.engine.impl.model.KServeVisionEngine; +import prerna.engine.impl.model.ModelRouterEngine; import prerna.engine.impl.model.NEREngine; import prerna.engine.impl.model.OpenAiEngine; import prerna.engine.impl.model.TextEmbeddingsEngine; @@ -63,6 +64,9 @@ public enum ModelTypeEnum { REMOTE("REMOTE", RemoteModelEngine.class.getName()), TEXT_EMBEDDINGS("TEXT_EMBEDDINGS", TextEmbeddingsEngine.class.getName()), TEXT_GENERATION("TEXT_GENERATION", TextGenerationEngine.class.getName()), + + // routing engine — dispatches to backing engines based on keyword or LLM classification + MODEL_ROUTER("MODEL_ROUTER", ModelRouterEngine.class.getName()), ; // @formatter:on diff --git a/src/prerna/engine/impl/model/ModelRouterEngine.java b/src/prerna/engine/impl/model/ModelRouterEngine.java new file mode 100644 index 00000000000..82ae99fd67f --- /dev/null +++ b/src/prerna/engine/impl/model/ModelRouterEngine.java @@ -0,0 +1,477 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.impl.model; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.google.gson.Gson; + +import prerna.engine.api.IEngine; +import prerna.engine.api.IModelEngine; +import prerna.engine.api.ModelTypeEnum; +import prerna.engine.impl.model.message.InputMessage; +import prerna.engine.impl.model.message.ResponseMessage; +import prerna.engine.impl.model.responses.AskModelEngineResponse; +import prerna.engine.impl.model.responses.EmbeddingsModelEngineResponse; +import prerna.om.Insight; +import prerna.om.InsightStore; +import prerna.util.EngineUtility; +import prerna.util.Utility; + +/** + * ModelRouterEngine is a routing {@link IModelEngine} that dispatches each + * query to one of several backing engines based on keyword matching or + * LLM-based classification. It extends {@link AbstractModelEngine} so all + * standard inference-logging and usage-restriction logic continues to apply + * at the ModelRouterEngine level. + * + *

SMSS configuration keys

+ *
+ * ENGINE_TYPE             = prerna.engine.impl.model.ModelRouterEngine
+ * MODEL_TYPE              = MODEL_ROUTER
+ *
+ * # Number of routes (required)
+ * ROUTE_COUNT             = 2
+ *
+ * # Route 0 — sports queries go to GPT
+ * ROUTE_0_NAME            = sports
+ * ROUTE_0_ENGINE_ID       = 8380e91f-7c0b-46a1-ad2a-d795b24037b5
+ * ROUTE_0_KEYWORDS        = sports,nba,nfl,score,player,game,match,tournament
+ *
+ * # Route 1 — code/weather queries go to Claude
+ * ROUTE_1_NAME            = code
+ * ROUTE_1_ENGINE_ID       = aa876e7e-e78e-404d-b7db-1a44236bc2a5
+ * ROUTE_1_KEYWORDS        = code,python,java,function,debug,error,weather,forecast
+ *
+ * # Fallback when no keyword matches (defaults to ROUTE_0 if omitted)
+ * DEFAULT_ROUTE_ENGINE_ID = aa876e7e-e78e-404d-b7db-1a44236bc2a5
+ *
+ * # "keyword" (default) or "llm" — LLM mode calls CLASSIFIER_ENGINE_ID first
+ * CLASSIFIER_MODE         = keyword
+ *
+ * # Only needed when CLASSIFIER_MODE = llm
+ * CLASSIFIER_ENGINE_ID    = aa876e7e-e78e-404d-b7db-1a44236bc2a5
+ *
+ * # Engine used for embeddings delegation (falls back to DEFAULT_ROUTE_ENGINE_ID)
+ * EMBEDDINGS_ENGINE_ID    = aa876e7e-e78e-404d-b7db-1a44236bc2a5
+ * 
+ */ +public class ModelRouterEngine extends AbstractModelEngine { + + private static final Logger classLogger = LogManager.getLogger(ModelRouterEngine.class); + + // SMSS property key constants + public static final String ROUTER_CONFIG = "ROUTER_CONFIG"; + public static final String ROUTE_COUNT = "ROUTE_COUNT"; + public static final String ROUTE_NAME_SUFFIX = "_NAME"; + public static final String ROUTE_ENGINE_SUFFIX = "_ENGINE_ID"; + public static final String ROUTE_KEYWORDS_SUFFIX = "_KEYWORDS"; + public static final String ROUTE_WEIGHT_SUFFIX = "_WEIGHT"; + public static final String DEFAULT_ROUTE_ENGINE_ID = "DEFAULT_ROUTE_ENGINE_ID"; + public static final String CLASSIFIER_MODE = "CLASSIFIER_MODE"; + public static final String CLASSIFIER_ENGINE_ID = "CLASSIFIER_ENGINE_ID"; + public static final String EMBEDDINGS_ENGINE_ID = "EMBEDDINGS_ENGINE_ID"; + + private static final String MODE_KEYWORD = "keyword"; + private static final String MODE_LLM = "llm"; + private static final String MODE_WEIGHTED = "weighted"; + + // ------------------------------------------------------------------------- + // Internal route descriptor + // ------------------------------------------------------------------------- + private static class Route { + final String name; + final String engineId; + final List keywords; + final int weight; + + Route(String name, String engineId, List keywords, int weight) { + this.name = name; + this.engineId = engineId; + this.keywords = keywords; + this.weight = weight; + } + } + + private final List routes = new ArrayList<>(); + private String defaultRouteEngineId; + private String classifierMode = MODE_KEYWORD; + private String classifierEngineId; + private String embeddingsEngineId; + /** Round-robin counter for weighted mode — increments on every weighted call. */ + private final AtomicInteger rrCounter = new AtomicInteger(0); + + // ------------------------------------------------------------------------- + // IModelEngine + // ------------------------------------------------------------------------- + + @Override + public ModelTypeEnum getModelType() { + return ModelTypeEnum.MODEL_ROUTER; + } + + @Override + public void close() throws IOException { + // ModelRouterEngine holds no resources of its own. The backing engines it + // delegates to are loaded and closed independently by the platform, so there + // is nothing to tear down here. + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + @Override + public void open(Properties smssProp) throws Exception { + super.open(smssProp); + + String configFile = this.smssProp.getProperty(ROUTER_CONFIG); + if (configFile != null && !configFile.trim().isEmpty()) { + loadFromJson(configFile.trim()); + } else { + loadFromProperties(); + } + + classLogger.info("ModelRouterEngine '{}' loaded: {} route(s), classifierMode={}", + this.engineId, routes.size(), this.classifierMode); + } + + /** + * Load router config from a JSON file in the engine's assets folder (Portkey-style). + * Schema: + * { + * "mode": "weighted|llm|keyword", + * "default_route": "", + * "classifier_engine": "", + * "embeddings_engine": "", + * "routes": [ + * { "name": "claude", "engine_id": "abc...", "weight": 30, "keywords": ["code","debug"] } + * ] + * } + */ + private void loadFromJson(String configFile) throws IOException { + String assetsFolder = EngineUtility.getSpecificEngineAssetsFolder( + IEngine.CATALOG_TYPE.MODEL, this.engineId, this.engineName); + File jsonFile = new File((assetsFolder + "/" + configFile).replace("\\", "/")); + if (!jsonFile.exists()) { + throw new IOException("ModelRouterEngine: " + ROUTER_CONFIG + " file not found at " + jsonFile.getAbsolutePath()); + } + + RouterConfig cfg; + try (Reader reader = new FileReader(jsonFile)) { + cfg = new Gson().fromJson(reader, RouterConfig.class); + } + if (cfg == null || cfg.routes == null || cfg.routes.isEmpty()) { + throw new IllegalArgumentException("ModelRouterEngine: " + configFile + " must define at least one route"); + } + + for (int i = 0; i < cfg.routes.size(); i++) { + RouteConfig rc = cfg.routes.get(i); + if (rc.engine_id == null || rc.engine_id.trim().isEmpty()) { + throw new IllegalArgumentException("ModelRouterEngine: route " + i + " in " + configFile + " is missing engine_id"); + } + String name = (rc.name != null && !rc.name.trim().isEmpty()) ? rc.name.trim() : ("ROUTE_" + i); + List keywords = new ArrayList<>(); + if (rc.keywords != null) { + for (String kw : rc.keywords) { + if (kw != null && !kw.trim().isEmpty()) keywords.add(kw.trim().toLowerCase()); + } + } + routes.add(new Route(name, rc.engine_id.trim(), keywords, Math.max(0, rc.weight))); + } + + this.defaultRouteEngineId = trimOrNull(cfg.default_route); + this.classifierEngineId = trimOrNull(cfg.classifier_engine); + this.embeddingsEngineId = trimOrNull(cfg.embeddings_engine); + if (cfg.mode != null && !cfg.mode.trim().isEmpty()) { + this.classifierMode = cfg.mode.trim().toLowerCase(); + } + classLogger.info("[ModelRouter] Loaded config from {}", jsonFile.getName()); + } + + /** + * Legacy loader: parse ROUTE_x_* properties directly from the SMSS. Kept for + * backward compatibility with SMSS files that don't define ROUTER_CONFIG. + */ + private void loadFromProperties() { + String routeCountStr = this.smssProp.getProperty(ROUTE_COUNT); + if (routeCountStr == null || routeCountStr.trim().isEmpty()) { + throw new IllegalArgumentException("ModelRouterEngine requires either " + ROUTER_CONFIG + " or " + ROUTE_COUNT + " in its SMSS file"); + } + + int routeCount; + try { + routeCount = Integer.parseInt(routeCountStr.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("ModelRouterEngine: " + ROUTE_COUNT + " must be an integer, got: " + routeCountStr); + } + + for (int i = 0; i < routeCount; i++) { + String prefix = "ROUTE_" + i; + String name = this.smssProp.getProperty(prefix + ROUTE_NAME_SUFFIX, prefix); + String engineId = this.smssProp.getProperty(prefix + ROUTE_ENGINE_SUFFIX); + String keywordsRaw = this.smssProp.getProperty(prefix + ROUTE_KEYWORDS_SUFFIX, ""); + String weightRaw = this.smssProp.getProperty(prefix + ROUTE_WEIGHT_SUFFIX, "0"); + + if (engineId == null || engineId.trim().isEmpty()) { + throw new IllegalArgumentException("ModelRouterEngine: route " + i + " is missing " + prefix + ROUTE_ENGINE_SUFFIX); + } + + int weight = 0; + try { + weight = Integer.parseInt(weightRaw.trim()); + } catch (NumberFormatException e) { + classLogger.warn("ModelRouterEngine: route {} has non-integer {}{} = '{}', defaulting weight to 0", + i, prefix, ROUTE_WEIGHT_SUFFIX, weightRaw); + } + + List keywords = new ArrayList<>(); + for (String kw : keywordsRaw.split(",")) { + String trimmed = kw.trim().toLowerCase(); + if (!trimmed.isEmpty()) { + keywords.add(trimmed); + } + } + routes.add(new Route(name.trim(), engineId.trim(), keywords, weight)); + } + + this.defaultRouteEngineId = trimOrNull(this.smssProp.getProperty(DEFAULT_ROUTE_ENGINE_ID)); + String mode = this.smssProp.getProperty(CLASSIFIER_MODE); + if (mode != null && !mode.trim().isEmpty()) { + this.classifierMode = mode.trim().toLowerCase(); + } + this.classifierEngineId = trimOrNull(this.smssProp.getProperty(CLASSIFIER_ENGINE_ID)); + this.embeddingsEngineId = trimOrNull(this.smssProp.getProperty(EMBEDDINGS_ENGINE_ID)); + } + + private static String trimOrNull(String s) { + return (s != null && !s.trim().isEmpty()) ? s.trim() : null; + } + + /** Gson DTO for the router JSON file. Field names must match JSON keys. */ + private static class RouterConfig { + String mode; + String default_route; + String classifier_engine; + String embeddings_engine; + List routes; + } + + private static class RouteConfig { + String name; + String engine_id; + int weight; + List keywords; + } + + // ------------------------------------------------------------------------- + // Core delegation + // ------------------------------------------------------------------------- + + @Override + @SuppressWarnings("deprecation") + protected AskModelEngineResponse askCall(String question, Object fullPrompt, String context, + Insight insight, String roomId, Map hyperParameters) { + + String routeEngineId = selectRoute(question, insight); + classLogger.info("[ModelRouter] '{}' -> question=\"{}\" | routing to engineId={}", + this.engineId, question, routeEngineId); + + IModelEngine targetEngine = resolveEngine(routeEngineId); + // Delegate via the public ask() API; inference logs for the target engine + // are written by that engine's own AbstractModelEngine wrapper. + return targetEngine.ask(question, context, insight, hyperParameters); + } + + @Override + protected EmbeddingsModelEngineResponse embeddingsCall(List stringsToEmbed, + Insight insight, Map parameters) { + + String engId = this.embeddingsEngineId != null ? this.embeddingsEngineId : fallbackEngineId(); + IModelEngine targetEngine = resolveEngine(engId); + return targetEngine.embeddings(stringsToEmbed, insight, parameters); + } + + // ------------------------------------------------------------------------- + // Routing logic + // ------------------------------------------------------------------------- + + private String selectRoute(String question, Insight insight) { + if (MODE_WEIGHTED.equalsIgnoreCase(this.classifierMode)) { + return selectRouteByWeight(); + } + if (MODE_LLM.equalsIgnoreCase(this.classifierMode) && this.classifierEngineId != null) { + return selectRouteByLLM(question, insight); + } + return selectRouteByKeyword(question); + } + + /** + * Weighted round-robin routing: distributes traffic in strict proportion to + * ROUTE_x_WEIGHT. A counter cycles 0..total-1 and each route owns a slice. + * e.g. weights 30/70 → positions 0-29 = claude, 30-99 = gpt, repeating exactly. + * Guarantees no route is starved — the split is exact over every full cycle. + */ + private String selectRouteByWeight() { + int sum = 0; + for (Route r : routes) { + if (r.weight > 0) sum += r.weight; + } + final int total = sum; + if (total <= 0) { + classLogger.warn("[ModelRouter] weighted mode but no positive ROUTE_x_WEIGHT set — using fallback engine"); + return fallbackEngineId(); + } + // Atomically grab the next position in the cycle and wrap at total + int pos = rrCounter.getAndUpdate(c -> (c + 1) % total); + int cumulative = 0; + for (Route r : routes) { + if (r.weight <= 0) continue; + cumulative += r.weight; + if (pos < cumulative) { + classLogger.info("[ModelRouter] Round-robin pos {}/{} -> route '{}' (weight {})", + pos, total, r.name, r.weight); + return r.engineId; + } + } + return fallbackEngineId(); + } + + /** + * Keyword routing: returns the first route whose keyword list contains a match + * anywhere in the lower-cased question. Falls back to the default engine. + */ + private String selectRouteByKeyword(String question) { + String lowerQ = question.toLowerCase(); + for (Route route : routes) { + for (String kw : route.keywords) { + if (lowerQ.contains(kw)) { + classLogger.info("[ModelRouter] Keyword '{}' matched route '{}'", kw, route.name); + return route.engineId; + } + } + } + classLogger.info("[ModelRouter] No keyword matched \u2014 using fallback engine"); + return fallbackEngineId(); + } + + /** + * LLM routing: sends a compact classification prompt to the classifier engine, + * expects exactly one route name back, then resolves it. Gracefully degrades to + * keyword routing if the LLM call fails or returns an unrecognised name. + */ + @SuppressWarnings("deprecation") + private String selectRouteByLLM(String question, Insight insight) { + Insight classificationInsight = new Insight(); + InsightStore.getInstance().put(classificationInsight); + try { + StringBuilder routeList = new StringBuilder(); + for (Route r : routes) { + routeList.append("- ").append(r.name); + if (r.keywords != null && !r.keywords.isEmpty()) { + routeList.append(" (for questions about: ") + .append(String.join(", ", r.keywords)) + .append(")"); + } + routeList.append("\n"); + } + + String classificationPrompt = + "You are a routing classifier. Given the user question below, " + + "reply with ONLY the single route name that best matches — no explanation, no punctuation, no quotes.\n\n" + + "Available routes:\n" + routeList + + "\nUser question: " + question + + "\n\nRoute name:"; + + IModelEngine classifierEngine = resolveEngine(this.classifierEngineId); + + // Run classification in an ISOLATED room/insight so it never pollutes the + // caller's conversation. use_history=false keeps it a clean one-shot call. + Room room = RoomUtils.createRoomIfNotExists( + UUID.randomUUID().toString(), classificationInsight, classifierEngine, classificationPrompt); + Map params = new HashMap<>(); + params.put("use_history", false); + InputMessage msg = InputMessage.builder(room) + .withText(classificationPrompt) + .withModelType(classifierEngine.getModelType()) + .withParamMap(params) + .build(); + ResponseMessage response = room.ask(msg, classifierEngine); + Object responseObj = response.getModelEngineResponse().toMap().get("response"); + String routeName = responseObj != null ? responseObj.toString().trim() : ""; + + for (Route route : routes) { + if (route.name.equalsIgnoreCase(routeName)) { + classLogger.info("[ModelRouter] LLM classified question as route '{}'", routeName); + return route.engineId; + } + } + classLogger.warn("ModelRouterEngine: LLM returned unknown route '{}', falling back to keyword", routeName); + } catch (Exception e) { + classLogger.error("ModelRouterEngine: LLM classification failed, falling back to keyword", e); + } finally { + InsightStore.getInstance().remove(classificationInsight.getInsightId()); + } + return selectRouteByKeyword(question); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private String fallbackEngineId() { + if (this.defaultRouteEngineId != null && !this.defaultRouteEngineId.isEmpty()) { + return this.defaultRouteEngineId; + } + if (!routes.isEmpty()) { + return routes.get(0).engineId; + } + throw new IllegalStateException("ModelRouterEngine: no routes configured and no default engine set"); + } + + private IModelEngine resolveEngine(String engineId) { + IModelEngine engine = (IModelEngine) Utility.getEngine(engineId); + if (engine == null) { + throw new IllegalStateException("ModelRouterEngine: could not load engine with id=" + engineId); + } + return engine; + } +} From f6f321d822187fecfb5229457086d8b33d7b86a5 Mon Sep 17 00:00:00 2001 From: Ryan Weiler Date: Sun, 16 Aug 2026 19:46:31 -0400 Subject: [PATCH 2/3] WIP --- src/prerna/engine/api/ModelTypeEnum.java | 2 +- .../impl/model/AbstractModelEngine.java | 14 +- .../engine/impl/model/ModelRouterEngine.java | 700 +++++++++++++----- 3 files changed, 547 insertions(+), 169 deletions(-) diff --git a/src/prerna/engine/api/ModelTypeEnum.java b/src/prerna/engine/api/ModelTypeEnum.java index ce422886c3b..2398690f825 100644 --- a/src/prerna/engine/api/ModelTypeEnum.java +++ b/src/prerna/engine/api/ModelTypeEnum.java @@ -65,7 +65,7 @@ public enum ModelTypeEnum { TEXT_EMBEDDINGS("TEXT_EMBEDDINGS", TextEmbeddingsEngine.class.getName()), TEXT_GENERATION("TEXT_GENERATION", TextGenerationEngine.class.getName()), - // routing engine — dispatches to backing engines based on keyword or LLM classification + // routing engine - dispatches to backing engines per its assets/router.json config MODEL_ROUTER("MODEL_ROUTER", ModelRouterEngine.class.getName()), ; // @formatter:on diff --git a/src/prerna/engine/impl/model/AbstractModelEngine.java b/src/prerna/engine/impl/model/AbstractModelEngine.java index 592d02e4dd0..86140460177 100644 --- a/src/prerna/engine/impl/model/AbstractModelEngine.java +++ b/src/prerna/engine/impl/model/AbstractModelEngine.java @@ -500,7 +500,7 @@ public AskModelEngineResponse askRoom(String question, Room room, AbstractMessag Thread inferenceRecorder = new Thread(new ModelEngineInferenceLogsWorker ( /*messageId*/ inputMessage.getMessageId(), /*transactionId*/askModelResponse.getMessageId(), - /*messageMethod*/"ask", + /*messageMethod*/inferenceLogMessageMethod("ask"), /*engine*/this, /*insightId*/room.getInsight().getInsightId(), /*projectContextId*/room.getInsight().getContextProjectId(), @@ -573,6 +573,16 @@ public AskModelEngineResponse askRoom(String question, Room room, AbstractMessag } } + /** + * messageMethod recorded on inference log rows written by this engine. + * Delegating engines (e.g. the model router) override this to tag their + * rows, so ask-history queries and usage aggregations can separate the + * delegating row from the actual model call. + */ + protected String inferenceLogMessageMethod(String method) { + return method; + } + @Override @Deprecated public AskModelEngineResponse ask(String question, String context, Insight insight, @@ -613,7 +623,7 @@ public EmbeddingsModelEngineResponse embeddings(List stringsToEmbed, Ins Thread inferenceRecorder = new Thread(new ModelEngineInferenceLogsWorker ( /*messageId*/messageId, /*transactionId*/messageId, - /*messageMethod*/"embeddings", + /*messageMethod*/inferenceLogMessageMethod("embeddings"), /*engine*/this, /*insightId*/insight.getInsightId(), /*projectContextId*/insight.getContextProjectId(), diff --git a/src/prerna/engine/impl/model/ModelRouterEngine.java b/src/prerna/engine/impl/model/ModelRouterEngine.java index 82ae99fd67f..ad42eb57e58 100644 --- a/src/prerna/engine/impl/model/ModelRouterEngine.java +++ b/src/prerna/engine/impl/model/ModelRouterEngine.java @@ -29,91 +29,141 @@ import java.io.File; import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; import java.io.Reader; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; -import java.util.UUID; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.google.gson.Gson; +import prerna.auth.User; +import prerna.auth.utils.SecurityEngineUtils; import prerna.engine.api.IEngine; import prerna.engine.api.IModelEngine; import prerna.engine.api.ModelTypeEnum; +import prerna.engine.impl.model.message.AbstractMessage; import prerna.engine.impl.model.message.InputMessage; -import prerna.engine.impl.model.message.ResponseMessage; +import prerna.engine.impl.model.message.MessageUtils; import prerna.engine.impl.model.responses.AskModelEngineResponse; import prerna.engine.impl.model.responses.EmbeddingsModelEngineResponse; import prerna.om.Insight; -import prerna.om.InsightStore; import prerna.util.EngineUtility; import prerna.util.Utility; /** * ModelRouterEngine is a routing {@link IModelEngine} that dispatches each - * query to one of several backing engines based on keyword matching or - * LLM-based classification. It extends {@link AbstractModelEngine} so all - * standard inference-logging and usage-restriction logic continues to apply - * at the ModelRouterEngine level. + * request to one of several backing model engines. * - *

SMSS configuration keys

+ *

All routing configuration lives in a JSON file in the engine's assets + * folder. By convention the engine loads router.json; the optional SMSS + * property ROUTER_CONFIG overrides the file name. Beyond that, the SMSS file + * only needs the standard engine identity keys: *

- * ENGINE_TYPE             = prerna.engine.impl.model.ModelRouterEngine
- * MODEL_TYPE              = MODEL_ROUTER
+ * ENGINE_TYPE = prerna.engine.impl.model.ModelRouterEngine
+ * MODEL_TYPE  = MODEL_ROUTER
+ * 
* - * # Number of routes (required) - * ROUTE_COUNT = 2 + *

router.json schema

+ *
+ * {
+ *   "mode": "keyword",                 // "keyword" | "llm" | "weighted"
+ *   "sticky": true,                    // pin a conversation to the route that first serves it (default true)
+ *   "default_route": "<engineId>",     // used when no route matches (defaults to route 0)
+ *   "fallbacks": ["<engineId>"],       // tried in order when the chosen target fails
+ *   "classifier_engine": "<engineId>", // required for "llm" mode
+ *   "embeddings_engine": "<engineId>", // required for the router to serve embeddings
+ *   "routes": [
+ *     { "name": "code",   "engine_id": "aa876e7e-...", "keywords": ["java", "python", "debug"], "weight": 70 },
+ *     { "name": "sports", "engine_id": "8380e91f-...", "keywords": ["nba", "nfl", "score"],     "weight": 30 }
+ *   ]
+ * }
+ * 
* - * # Route 0 — sports queries go to GPT - * ROUTE_0_NAME = sports - * ROUTE_0_ENGINE_ID = 8380e91f-7c0b-46a1-ad2a-d795b24037b5 - * ROUTE_0_KEYWORDS = sports,nba,nfl,score,player,game,match,tournament + *

Modes

+ *
    + *
  • keyword - first route with a whole-word keyword match on the + * latest user message wins; otherwise the default route.
  • + *
  • llm - the classifier engine is asked to pick a route by name; + * falls back to keyword matching when classification fails.
  • + *
  • weighted - deterministic weighted round-robin across routes with + * weight > 0 (weights 30/70 give an exact 30/70 split every cycle).
  • + *
* - * # Route 1 — code/weather queries go to Claude - * ROUTE_1_NAME = code - * ROUTE_1_ENGINE_ID = aa876e7e-e78e-404d-b7db-1a44236bc2a5 - * ROUTE_1_KEYWORDS = code,python,java,function,debug,error,weather,forecast + *

Sticky routing

+ * When sticky is on (the default), the first turn of a room selects a route + * and later turns reuse it, so a conversation stays on one model and llm mode + * pays the classifier cost only once per room. Under weighted mode this makes + * the traffic split per-conversation rather than per-request. A pin is dropped + * when its engine fails and the turn is served by a failover candidate. Pins + * are held in a bounded in-memory map per router instance, so they reset on + * engine reload and are not shared across nodes. * - * # Fallback when no keyword matches (defaults to ROUTE_0 if omitted) - * DEFAULT_ROUTE_ENGINE_ID = aa876e7e-e78e-404d-b7db-1a44236bc2a5 + *

Failover

+ * When the chosen target fails (engine will not load, or the ask errors), the + * router tries the fallbacks list in order and finally the default route. The + * last failure is rethrown when every candidate fails. * - * # "keyword" (default) or "llm" — LLM mode calls CLASSIFIER_ENGINE_ID first - * CLASSIFIER_MODE = keyword + *

Access control

+ * Access to the router does NOT implicitly grant its backing engines: each + * candidate target (ask and embeddings) is checked with + * {@link SecurityEngineUtils#userCanViewEngine(User, String)} for the calling + * user, and denied candidates are skipped. The classifier engine is exempt - + * it is internal plumbing whose output the user never sees directly. * - * # Only needed when CLASSIFIER_MODE = llm - * CLASSIFIER_ENGINE_ID = aa876e7e-e78e-404d-b7db-1a44236bc2a5 + *

Inference logs

+ * Requests are logged twice by design: once under this router's engine id + * (user-facing attribution) and once under the delegated engine's id (actual + * model usage). The router's rows are tagged with messageMethod "route_ask" / + * "route_embeddings" so ask-history queries and usage aggregations only count + * the delegated engine's "ask" / "embeddings" rows. * - * # Engine used for embeddings delegation (falls back to DEFAULT_ROUTE_ENGINE_ID) - * EMBEDDINGS_ENGINE_ID = aa876e7e-e78e-404d-b7db-1a44236bc2a5 - * + *

The chosen target is surfaced on the response metadata under the + * router_engine_id / routed_engine_id / routed_route_name keys. */ public class ModelRouterEngine extends AbstractModelEngine { private static final Logger classLogger = LogManager.getLogger(ModelRouterEngine.class); - // SMSS property key constants - public static final String ROUTER_CONFIG = "ROUTER_CONFIG"; - public static final String ROUTE_COUNT = "ROUTE_COUNT"; - public static final String ROUTE_NAME_SUFFIX = "_NAME"; - public static final String ROUTE_ENGINE_SUFFIX = "_ENGINE_ID"; - public static final String ROUTE_KEYWORDS_SUFFIX = "_KEYWORDS"; - public static final String ROUTE_WEIGHT_SUFFIX = "_WEIGHT"; - public static final String DEFAULT_ROUTE_ENGINE_ID = "DEFAULT_ROUTE_ENGINE_ID"; - public static final String CLASSIFIER_MODE = "CLASSIFIER_MODE"; - public static final String CLASSIFIER_ENGINE_ID = "CLASSIFIER_ENGINE_ID"; - public static final String EMBEDDINGS_ENGINE_ID = "EMBEDDINGS_ENGINE_ID"; + /** Optional SMSS property overriding the config file name in the assets folder. */ + public static final String ROUTER_CONFIG = "ROUTER_CONFIG"; + /** Conventional config file name looked up when ROUTER_CONFIG is not set. */ + public static final String DEFAULT_CONFIG_FILE = "router.json"; + /** + * Optional SMSS property holding the initial config JSON inline. Engine + * creation from the UI opens the engine before any asset can be uploaded, so + * this is read once to seed the config file when it does not exist yet. The + * file is the source of truth afterwards - later edits belong in the file. + */ + public static final String ROUTER_CONFIG_JSON = "ROUTER_CONFIG_JSON"; + + /** Response metadata keys describing the routing decision. */ + public static final String METADATA_ROUTER_ENGINE_ID = "router_engine_id"; + public static final String METADATA_ROUTED_ENGINE_ID = "routed_engine_id"; + public static final String METADATA_ROUTED_ROUTE_NAME = "routed_route_name"; private static final String MODE_KEYWORD = "keyword"; private static final String MODE_LLM = "llm"; private static final String MODE_WEIGHTED = "weighted"; + private static final String MESSAGE_JSON = "message_json"; + + private static final int MAX_STICKY_ROOMS = 10_000; + // ------------------------------------------------------------------------- // Internal route descriptor // ------------------------------------------------------------------------- @@ -122,22 +172,38 @@ private static class Route { final String engineId; final List keywords; final int weight; + /** Whole-word matcher over all keywords; null when the route has none. */ + final Pattern keywordPattern; Route(String name, String engineId, List keywords, int weight) { this.name = name; this.engineId = engineId; this.keywords = keywords; this.weight = weight; + this.keywordPattern = buildKeywordPattern(keywords); } } private final List routes = new ArrayList<>(); + private String routingMode = MODE_KEYWORD; + private boolean sticky = true; private String defaultRouteEngineId; - private String classifierMode = MODE_KEYWORD; + private final List fallbackEngineIds = new ArrayList<>(); private String classifierEngineId; private String embeddingsEngineId; - /** Round-robin counter for weighted mode — increments on every weighted call. */ + private int totalWeight = 0; + /** Round-robin counter for weighted mode - increments on every weighted call. */ private final AtomicInteger rrCounter = new AtomicInteger(0); + /** Lazily computed min context window across serving targets; null = not yet computed. */ + private volatile Integer derivedContextWindow; + /** LRU of roomId -> engineId that last served the room, used when sticky is on. */ + private final Map roomRoutePins = Collections.synchronizedMap( + new LinkedHashMap(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_STICKY_ROOMS; + } + }); // ------------------------------------------------------------------------- // IModelEngine @@ -155,6 +221,59 @@ public void close() throws IOException { // is nothing to tear down here. } + /** + * Callers sizing work off this engine (e.g. agent auto-compaction) cannot + * know which route will serve them, so answer with the smallest context + * window among the serving targets: routes, default route, and fallbacks. + * An explicit CONTEXT_WINDOW in the smss/metadata still wins. Targets that + * fail to load or do not report a window are skipped; when none report one, + * 0 is returned and callers treat it as unknown. Computed once on first use, + * so a router reload picks up target changes. + */ + @Override + public int getContextWindow() { + int inherited = super.getContextWindow(); + if (inherited > 0) { + return inherited; + } + Integer derived = this.derivedContextWindow; + if (derived == null) { + derived = computeMinTargetContextWindow(); + this.derivedContextWindow = derived; + } + return derived.intValue(); + } + + private int computeMinTargetContextWindow() { + int min = 0; + for (String targetEngineId : servingEngineIds()) { + try { + IModelEngine engine = resolveEngine(targetEngineId); + int contextWindow = engine.getContextWindow(); + if (contextWindow > 0 && (min == 0 || contextWindow < min)) { + min = contextWindow; + } + } catch (Exception e) { + classLogger.warn("ModelRouterEngine '{}': could not resolve context window for engineId={}", + this.engineId, targetEngineId, e); + } + } + return min; + } + + /** Every engine that could serve an ask: routes, default route, fallbacks. */ + private List servingEngineIds() { + List ids = new ArrayList<>(); + for (Route route : routes) { + addCandidate(ids, route.engineId); + } + addCandidate(ids, this.defaultRouteEngineId); + for (String fallback : this.fallbackEngineIds) { + addCandidate(ids, fallback); + } + return ids; + } + // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- @@ -164,35 +283,54 @@ public void open(Properties smssProp) throws Exception { super.open(smssProp); String configFile = this.smssProp.getProperty(ROUTER_CONFIG); - if (configFile != null && !configFile.trim().isEmpty()) { - loadFromJson(configFile.trim()); - } else { - loadFromProperties(); + if (configFile == null || configFile.trim().isEmpty()) { + configFile = DEFAULT_CONFIG_FILE; } + configFile = configFile.trim(); + bootstrapConfigFileIfNeeded(configFile); + loadFromJson(configFile); + validateConfig(configFile); + + classLogger.info("ModelRouterEngine '{}' loaded: {} route(s), mode={}, sticky={}", + this.engineId, routes.size(), this.routingMode, this.sticky); + } - classLogger.info("ModelRouterEngine '{}' loaded: {} route(s), classifierMode={}", - this.engineId, routes.size(), this.classifierMode); + private File resolveConfigFile(String configFile) { + String assetsFolder = EngineUtility.getSpecificEngineAssetsFolder( + IEngine.CATALOG_TYPE.MODEL, this.engineId, this.engineName); + return new File((assetsFolder + "/" + configFile).replace("\\", "/")); } /** - * Load router config from a JSON file in the engine's assets folder (Portkey-style). - * Schema: - * { - * "mode": "weighted|llm|keyword", - * "default_route": "", - * "classifier_engine": "", - * "embeddings_engine": "", - * "routes": [ - * { "name": "claude", "engine_id": "abc...", "weight": 30, "keywords": ["code","debug"] } - * ] - * } + * Seed the config file from the ROUTER_CONFIG_JSON smss property when the + * file does not exist yet. Never overwrites an existing file. */ + private void bootstrapConfigFileIfNeeded(String configFile) throws IOException { + File jsonFile = resolveConfigFile(configFile); + if (jsonFile.exists()) { + return; + } + String bootstrapJson = this.smssProp.getProperty(ROUTER_CONFIG_JSON); + if (bootstrapJson == null || bootstrapJson.trim().isEmpty()) { + return; + } + File parentFolder = jsonFile.getParentFile(); + if (parentFolder != null && !parentFolder.exists() && !parentFolder.mkdirs()) { + throw new IOException("ModelRouterEngine: could not create assets folder " + parentFolder.getAbsolutePath()); + } + try (FileWriter writer = new FileWriter(jsonFile)) { + writer.write(bootstrapJson.trim()); + } + classLogger.info("ModelRouterEngine '{}' seeded {} from the {} smss property", + this.engineId, jsonFile.getName(), ROUTER_CONFIG_JSON); + } + private void loadFromJson(String configFile) throws IOException { - String assetsFolder = EngineUtility.getSpecificEngineAssetsFolder( - IEngine.CATALOG_TYPE.MODEL, this.engineId, this.engineName); - File jsonFile = new File((assetsFolder + "/" + configFile).replace("\\", "/")); + File jsonFile = resolveConfigFile(configFile); if (!jsonFile.exists()) { - throw new IOException("ModelRouterEngine: " + ROUTER_CONFIG + " file not found at " + jsonFile.getAbsolutePath()); + throw new IOException("ModelRouterEngine: routing config not found at " + jsonFile.getAbsolutePath() + + ". Place a " + DEFAULT_CONFIG_FILE + " in the engine assets folder" + + " (or set " + ROUTER_CONFIG + " in the SMSS to use a different file name)."); } RouterConfig cfg; @@ -212,7 +350,9 @@ private void loadFromJson(String configFile) throws IOException { List keywords = new ArrayList<>(); if (rc.keywords != null) { for (String kw : rc.keywords) { - if (kw != null && !kw.trim().isEmpty()) keywords.add(kw.trim().toLowerCase()); + if (kw != null && !kw.trim().isEmpty()) { + keywords.add(kw.trim().toLowerCase()); + } } } routes.add(new Route(name, rc.engine_id.trim(), keywords, Math.max(0, rc.weight))); @@ -222,74 +362,108 @@ private void loadFromJson(String configFile) throws IOException { this.classifierEngineId = trimOrNull(cfg.classifier_engine); this.embeddingsEngineId = trimOrNull(cfg.embeddings_engine); if (cfg.mode != null && !cfg.mode.trim().isEmpty()) { - this.classifierMode = cfg.mode.trim().toLowerCase(); + this.routingMode = cfg.mode.trim().toLowerCase(); + } + if (cfg.sticky != null) { + this.sticky = cfg.sticky.booleanValue(); + } + if (cfg.fallbacks != null) { + for (String fb : cfg.fallbacks) { + String trimmed = trimOrNull(fb); + if (trimmed != null) { + this.fallbackEngineIds.add(trimmed); + } + } } - classLogger.info("[ModelRouter] Loaded config from {}", jsonFile.getName()); } /** - * Legacy loader: parse ROUTE_x_* properties directly from the SMSS. Kept for - * backward compatibility with SMSS files that don't define ROUTER_CONFIG. + * Fail engine open on configurations that would otherwise silently degrade + * at request time (unknown mode, llm without a classifier, weighted without + * weights, a route pointing back at this router). */ - private void loadFromProperties() { - String routeCountStr = this.smssProp.getProperty(ROUTE_COUNT); - if (routeCountStr == null || routeCountStr.trim().isEmpty()) { - throw new IllegalArgumentException("ModelRouterEngine requires either " + ROUTER_CONFIG + " or " + ROUTE_COUNT + " in its SMSS file"); + private void validateConfig(String configFile) { + if (!MODE_KEYWORD.equals(this.routingMode) + && !MODE_LLM.equals(this.routingMode) + && !MODE_WEIGHTED.equals(this.routingMode)) { + throw new IllegalArgumentException("ModelRouterEngine: unknown mode '" + this.routingMode + + "' in " + configFile + ". Valid modes are: " + + MODE_KEYWORD + ", " + MODE_LLM + ", " + MODE_WEIGHTED); } - int routeCount; - try { - routeCount = Integer.parseInt(routeCountStr.trim()); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("ModelRouterEngine: " + ROUTE_COUNT + " must be an integer, got: " + routeCountStr); + if (MODE_LLM.equals(this.routingMode) && this.classifierEngineId == null) { + throw new IllegalArgumentException("ModelRouterEngine: mode 'llm' requires classifier_engine in " + configFile); } - for (int i = 0; i < routeCount; i++) { - String prefix = "ROUTE_" + i; - String name = this.smssProp.getProperty(prefix + ROUTE_NAME_SUFFIX, prefix); - String engineId = this.smssProp.getProperty(prefix + ROUTE_ENGINE_SUFFIX); - String keywordsRaw = this.smssProp.getProperty(prefix + ROUTE_KEYWORDS_SUFFIX, ""); - String weightRaw = this.smssProp.getProperty(prefix + ROUTE_WEIGHT_SUFFIX, "0"); + for (Route r : routes) { + this.totalWeight += r.weight; + } + if (MODE_WEIGHTED.equals(this.routingMode) && this.totalWeight <= 0) { + throw new IllegalArgumentException("ModelRouterEngine: mode 'weighted' requires at least one route with weight > 0 in " + configFile); + } - if (engineId == null || engineId.trim().isEmpty()) { - throw new IllegalArgumentException("ModelRouterEngine: route " + i + " is missing " + prefix + ROUTE_ENGINE_SUFFIX); + Set seenNames = new HashSet<>(); + for (Route r : routes) { + if (!seenNames.add(r.name.toLowerCase())) { + throw new IllegalArgumentException("ModelRouterEngine: duplicate route name '" + r.name + "' in " + configFile); } + rejectSelfReference(r.engineId, "route '" + r.name + "'", configFile); + } + rejectSelfReference(this.defaultRouteEngineId, "default_route", configFile); + rejectSelfReference(this.classifierEngineId, "classifier_engine", configFile); + rejectSelfReference(this.embeddingsEngineId, "embeddings_engine", configFile); + for (String fb : this.fallbackEngineIds) { + rejectSelfReference(fb, "fallbacks", configFile); + } - int weight = 0; - try { - weight = Integer.parseInt(weightRaw.trim()); - } catch (NumberFormatException e) { - classLogger.warn("ModelRouterEngine: route {} has non-integer {}{} = '{}', defaulting weight to 0", - i, prefix, ROUTE_WEIGHT_SUFFIX, weightRaw); + if (MODE_KEYWORD.equals(this.routingMode)) { + boolean anyKeywords = false; + for (Route r : routes) { + anyKeywords = anyKeywords || !r.keywords.isEmpty(); } - - List keywords = new ArrayList<>(); - for (String kw : keywordsRaw.split(",")) { - String trimmed = kw.trim().toLowerCase(); - if (!trimmed.isEmpty()) { - keywords.add(trimmed); - } + if (!anyKeywords) { + classLogger.warn("ModelRouterEngine '{}': mode is 'keyword' but no route defines keywords - every request will use the default route", + this.engineId); } - routes.add(new Route(name.trim(), engineId.trim(), keywords, weight)); } + } - this.defaultRouteEngineId = trimOrNull(this.smssProp.getProperty(DEFAULT_ROUTE_ENGINE_ID)); - String mode = this.smssProp.getProperty(CLASSIFIER_MODE); - if (mode != null && !mode.trim().isEmpty()) { - this.classifierMode = mode.trim().toLowerCase(); + private void rejectSelfReference(String engineId, String field, String configFile) { + if (engineId != null && engineId.equals(this.engineId)) { + throw new IllegalArgumentException("ModelRouterEngine: " + field + " in " + configFile + + " points back at this router (" + this.engineId + ") - this would recurse forever"); } - this.classifierEngineId = trimOrNull(this.smssProp.getProperty(CLASSIFIER_ENGINE_ID)); - this.embeddingsEngineId = trimOrNull(this.smssProp.getProperty(EMBEDDINGS_ENGINE_ID)); } private static String trimOrNull(String s) { return (s != null && !s.trim().isEmpty()) ? s.trim() : null; } + /** + * Case-insensitive whole-word alternation over the route's keywords. Word + * edges are checked with alphanumeric lookarounds instead of \b so keywords + * containing symbols (e.g. "c++") still match. + */ + private static Pattern buildKeywordPattern(List keywords) { + if (keywords == null || keywords.isEmpty()) { + return null; + } + StringBuilder alternation = new StringBuilder(); + for (String kw : keywords) { + if (alternation.length() > 0) { + alternation.append("|"); + } + alternation.append(Pattern.quote(kw)); + } + return Pattern.compile("(?i)(? fallbacks; String classifier_engine; String embeddings_engine; List routes; @@ -307,26 +481,118 @@ private static class RouteConfig { // ------------------------------------------------------------------------- @Override - @SuppressWarnings("deprecation") + protected String inferenceLogMessageMethod(String method) { + // tag this router's own log rows so aggregations and ask-history queries + // only count the delegated engine's rows + return "route_" + method; + } + + @Override protected AskModelEngineResponse askCall(String question, Object fullPrompt, String context, Insight insight, String roomId, Map hyperParameters) { - String routeEngineId = selectRoute(question, insight); - classLogger.info("[ModelRouter] '{}' -> question=\"{}\" | routing to engineId={}", - this.engineId, question, routeEngineId); + // Reuses the caller's already-loaded room; the stateless lookup avoids + // re-acquiring the room mutation lock this request may already hold. + Room room = RoomUtils.createRoomForStatelessAsk(roomId, insight, this, null); + User user = insight != null ? insight.getUser() : null; + + String primaryEngineId = null; + if (this.sticky && roomId != null) { + String pinned = roomRoutePins.get(roomId); + if (pinned != null && userCanUseTarget(user, pinned)) { + primaryEngineId = pinned; + classLogger.debug("ModelRouterEngine '{}' room {} reusing pinned engineId={}", + this.engineId, roomId, pinned); + } + } + if (primaryEngineId == null) { + String routingText = extractRoutingText(question, room); + primaryEngineId = selectRoute(routingText, insight, room); + if (classLogger.isDebugEnabled()) { + classLogger.debug("ModelRouterEngine '{}' routing text: {}", + this.engineId, truncate(routingText, 200)); + } + } + + List candidates = buildCandidateList(primaryEngineId); + Exception lastFailure = null; + for (String candidateId : candidates) { + if (!userCanUseTarget(user, candidateId)) { + classLogger.warn("ModelRouterEngine '{}': user does not have access to engineId={} - skipping candidate", + this.engineId, candidateId); + continue; + } - IModelEngine targetEngine = resolveEngine(routeEngineId); - // Delegate via the public ask() API; inference logs for the target engine - // are written by that engine's own AbstractModelEngine wrapper. - return targetEngine.ask(question, context, insight, hyperParameters); + IModelEngine targetEngine; + try { + targetEngine = resolveEngine(candidateId); + } catch (Exception e) { + classLogger.warn("ModelRouterEngine '{}': could not load engineId={} - trying next candidate", + this.engineId, candidateId, e); + lastFailure = e; + continue; + } + + classLogger.info("ModelRouterEngine '{}' routing room {} to engineId={}", + this.engineId, roomId, candidateId); + try { + // Delegate straight to the target's askRoom with the caller's room and a + // fresh copy of the parameters, so message_json/tools pass through + // verbatim, provider-specific mutations from a failed attempt do not leak + // into the next one, and the target's inference log lands under the real + // room id. Room.ask is skipped on purpose: it would rebuild message_json + // from scratch and overwrite the room context in the DB when a system + // prompt is present. + Map params = hyperParameters != null + ? new HashMap<>(hyperParameters) + : new HashMap<>(); + InputMessage msg = InputMessage.builder(room) + .withSystemPrompt(context) + .withText(question) + .withModelType(targetEngine.getModelType()) + .withParamMap(params) + .build(); + AskModelEngineResponse response = targetEngine.askRoom(question, room, msg, params); + + if (this.sticky && roomId != null) { + roomRoutePins.put(roomId, candidateId); + } + attachRouteMetadata(response, candidateId); + return response; + } catch (Exception e) { + classLogger.warn("ModelRouterEngine '{}': engineId={} failed to serve the request - trying next candidate", + this.engineId, candidateId, e); + lastFailure = e; + if (this.sticky && roomId != null) { + // drop the pin so the next turn re-selects instead of retrying a dead engine + roomRoutePins.remove(roomId); + } + } + } + + if (lastFailure instanceof RuntimeException) { + throw (RuntimeException) lastFailure; + } + if (lastFailure != null) { + throw new IllegalStateException("ModelRouterEngine: all routing candidates failed", lastFailure); + } + throw new IllegalStateException("ModelRouterEngine: user does not have access to any configured route"); } @Override protected EmbeddingsModelEngineResponse embeddingsCall(List stringsToEmbed, Insight insight, Map parameters) { - String engId = this.embeddingsEngineId != null ? this.embeddingsEngineId : fallbackEngineId(); - IModelEngine targetEngine = resolveEngine(engId); + if (this.embeddingsEngineId == null) { + throw new IllegalStateException("ModelRouterEngine '" + this.engineId + + "': no embeddings_engine configured in the router config - this router cannot serve embeddings"); + } + User user = insight != null ? insight.getUser() : null; + if (!userCanUseTarget(user, this.embeddingsEngineId)) { + throw new IllegalStateException("ModelRouterEngine '" + this.engineId + + "': user does not have access to the embeddings engine " + this.embeddingsEngineId); + } + IModelEngine targetEngine = resolveEngine(this.embeddingsEngineId); return targetEngine.embeddings(stringsToEmbed, insight, parameters); } @@ -334,41 +600,67 @@ protected EmbeddingsModelEngineResponse embeddingsCall(List stringsToEmb // Routing logic // ------------------------------------------------------------------------- - private String selectRoute(String question, Insight insight) { - if (MODE_WEIGHTED.equalsIgnoreCase(this.classifierMode)) { + /** + * On the full-prompt path askCall receives the serialized conversation JSON + * as the question; routing on that blob would match keywords in the system + * prompt, tool definitions, and stale turns. Pull the latest user-authored + * message off the room instead, and fall back to the raw question text. + */ + private static String extractRoutingText(String question, Room room) { + String raw = question != null ? question.trim() : ""; + if (!raw.startsWith("[")) { + // plain-question path: askCall already received the user's text + return raw; + } + List messages = room.getMessages(); + for (int i = messages.size() - 1; i >= 0; i--) { + if (messages.get(i) instanceof InputMessage) { + InputMessage im = (InputMessage) messages.get(i); + // the UI prompt is only set on user-authored turns, which skips + // tool-result input messages in agent loops + String text = im.getInputUIPrompt(); + if (text == null || text.trim().isEmpty()) { + text = im.getInputPrompt(); + } + if (text != null && !text.trim().isEmpty()) { + return text.trim(); + } + } + } + return raw; + } + + private String selectRoute(String routingText, Insight insight, Room room) { + if (MODE_WEIGHTED.equals(this.routingMode)) { return selectRouteByWeight(); } - if (MODE_LLM.equalsIgnoreCase(this.classifierMode) && this.classifierEngineId != null) { - return selectRouteByLLM(question, insight); + if (MODE_LLM.equals(this.routingMode)) { + return selectRouteByLLM(routingText, insight, room); } - return selectRouteByKeyword(question); + return selectRouteByKeyword(routingText); } /** * Weighted round-robin routing: distributes traffic in strict proportion to - * ROUTE_x_WEIGHT. A counter cycles 0..total-1 and each route owns a slice. - * e.g. weights 30/70 → positions 0-29 = claude, 30-99 = gpt, repeating exactly. - * Guarantees no route is starved — the split is exact over every full cycle. + * the route weights. A counter cycles 0..total-1 and each route owns a slice. + * e.g. weights 30/70 give positions 0-29 to route 0 and 30-99 to route 1, + * repeating exactly, so the split is exact over every full cycle. */ private String selectRouteByWeight() { - int sum = 0; - for (Route r : routes) { - if (r.weight > 0) sum += r.weight; - } - final int total = sum; - if (total <= 0) { - classLogger.warn("[ModelRouter] weighted mode but no positive ROUTE_x_WEIGHT set — using fallback engine"); + if (this.totalWeight <= 0) { return fallbackEngineId(); } // Atomically grab the next position in the cycle and wrap at total - int pos = rrCounter.getAndUpdate(c -> (c + 1) % total); + int pos = rrCounter.getAndUpdate(c -> (c + 1) % this.totalWeight); int cumulative = 0; for (Route r : routes) { - if (r.weight <= 0) continue; + if (r.weight <= 0) { + continue; + } cumulative += r.weight; if (pos < cumulative) { - classLogger.info("[ModelRouter] Round-robin pos {}/{} -> route '{}' (weight {})", - pos, total, r.name, r.weight); + classLogger.info("ModelRouterEngine round-robin pos {}/{} -> route '{}' (weight {})", + pos, this.totalWeight, r.name, r.weight); return r.engineId; } } @@ -376,20 +668,21 @@ private String selectRouteByWeight() { } /** - * Keyword routing: returns the first route whose keyword list contains a match - * anywhere in the lower-cased question. Falls back to the default engine. + * Keyword routing: returns the first route with a whole-word keyword match + * in the routing text. Falls back to the default engine. */ - private String selectRouteByKeyword(String question) { - String lowerQ = question.toLowerCase(); + private String selectRouteByKeyword(String routingText) { for (Route route : routes) { - for (String kw : route.keywords) { - if (lowerQ.contains(kw)) { - classLogger.info("[ModelRouter] Keyword '{}' matched route '{}'", kw, route.name); - return route.engineId; - } + if (route.keywordPattern == null) { + continue; + } + Matcher matcher = route.keywordPattern.matcher(routingText); + if (matcher.find()) { + classLogger.info("ModelRouterEngine keyword '{}' matched route '{}'", matcher.group(1), route.name); + return route.engineId; } } - classLogger.info("[ModelRouter] No keyword matched \u2014 using fallback engine"); + classLogger.info("ModelRouterEngine: no keyword matched - using fallback engine"); return fallbackEngineId(); } @@ -398,15 +691,12 @@ private String selectRouteByKeyword(String question) { * expects exactly one route name back, then resolves it. Gracefully degrades to * keyword routing if the LLM call fails or returns an unrecognised name. */ - @SuppressWarnings("deprecation") - private String selectRouteByLLM(String question, Insight insight) { - Insight classificationInsight = new Insight(); - InsightStore.getInstance().put(classificationInsight); + private String selectRouteByLLM(String routingText, Insight insight, Room room) { try { StringBuilder routeList = new StringBuilder(); for (Route r : routes) { routeList.append("- ").append(r.name); - if (r.keywords != null && !r.keywords.isEmpty()) { + if (!r.keywords.isEmpty()) { routeList.append(" (for questions about: ") .append(String.join(", ", r.keywords)) .append(")"); @@ -416,49 +706,116 @@ private String selectRouteByLLM(String question, Insight insight) { String classificationPrompt = "You are a routing classifier. Given the user question below, " - + "reply with ONLY the single route name that best matches — no explanation, no punctuation, no quotes.\n\n" + + "reply with ONLY the single route name that best matches - no explanation, no punctuation, no quotes.\n\n" + "Available routes:\n" + routeList - + "\nUser question: " + question + + "\nUser question: " + routingText + "\n\nRoute name:"; IModelEngine classifierEngine = resolveEngine(this.classifierEngineId); - // Run classification in an ISOLATED room/insight so it never pollutes the - // caller's conversation. use_history=false keeps it a clean one-shot call. - Room room = RoomUtils.createRoomIfNotExists( - UUID.randomUUID().toString(), classificationInsight, classifierEngine, classificationPrompt); + // One-shot call against the caller's room and insight: askRoom is invoked + // directly (never Room.ask) so nothing is appended to the room history and + // no per-request classification rooms are written to the logs database. Map params = new HashMap<>(); - params.put("use_history", false); InputMessage msg = InputMessage.builder(room) .withText(classificationPrompt) .withModelType(classifierEngine.getModelType()) .withParamMap(params) .build(); - ResponseMessage response = room.ask(msg, classifierEngine); - Object responseObj = response.getModelEngineResponse().toMap().get("response"); - String routeName = responseObj != null ? responseObj.toString().trim() : ""; + params.put(MESSAGE_JSON, MessageUtils.toJsonArrayWithImageData(Arrays.asList(msg))); + AskModelEngineResponse response = classifierEngine.askRoom(classificationPrompt, room, msg, params); + + String routeName = response.getStringResponse(); + routeName = routeName != null ? routeName.trim() : ""; for (Route route : routes) { if (route.name.equalsIgnoreCase(routeName)) { - classLogger.info("[ModelRouter] LLM classified question as route '{}'", routeName); + classLogger.info("ModelRouterEngine: LLM classified question as route '{}'", routeName); return route.engineId; } } classLogger.warn("ModelRouterEngine: LLM returned unknown route '{}', falling back to keyword", routeName); } catch (Exception e) { classLogger.error("ModelRouterEngine: LLM classification failed, falling back to keyword", e); - } finally { - InsightStore.getInstance().remove(classificationInsight.getInsightId()); } - return selectRouteByKeyword(question); + return selectRouteByKeyword(routingText); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- + /** + * Ordered, deduplicated failover chain: the selected target first, then the + * configured fallbacks, then the default route as last resort. + */ + private List buildCandidateList(String primaryEngineId) { + List candidates = new ArrayList<>(); + addCandidate(candidates, primaryEngineId); + for (String fb : this.fallbackEngineIds) { + addCandidate(candidates, fb); + } + if (this.defaultRouteEngineId != null) { + addCandidate(candidates, this.defaultRouteEngineId); + } else if (!routes.isEmpty()) { + addCandidate(candidates, routes.get(0).engineId); + } + return candidates; + } + + private static void addCandidate(List candidates, String engineId) { + if (engineId != null && !candidates.contains(engineId)) { + candidates.add(engineId); + } + } + + /** + * Access to the router does not implicitly grant its backing engines; the + * caller must be able to view the target engine. Internal calls without a + * user are allowed through. + */ + private boolean userCanUseTarget(User user, String engineId) { + if (user == null) { + return true; + } + try { + return SecurityEngineUtils.userCanViewEngine(user, engineId); + } catch (Exception e) { + classLogger.warn("ModelRouterEngine '{}': access check failed for engineId={} - treating as denied", + this.engineId, engineId, e); + return false; + } + } + + private void attachRouteMetadata(AskModelEngineResponse response, String targetEngineId) { + try { + Map metadata = response.getMetadata(); + if (metadata == null) { + metadata = new HashMap<>(); + } + metadata.put(METADATA_ROUTER_ENGINE_ID, this.engineId); + metadata.put(METADATA_ROUTED_ENGINE_ID, targetEngineId); + String routeName = routeNameForEngine(targetEngineId); + if (routeName != null) { + metadata.put(METADATA_ROUTED_ROUTE_NAME, routeName); + } + response.setMetadata(metadata); + } catch (Exception e) { + classLogger.debug("ModelRouterEngine '{}': unable to attach route metadata", this.engineId, e); + } + } + + private String routeNameForEngine(String engineId) { + for (Route r : routes) { + if (r.engineId.equals(engineId)) { + return r.name; + } + } + return null; + } + private String fallbackEngineId() { - if (this.defaultRouteEngineId != null && !this.defaultRouteEngineId.isEmpty()) { + if (this.defaultRouteEngineId != null) { return this.defaultRouteEngineId; } if (!routes.isEmpty()) { @@ -468,10 +825,21 @@ private String fallbackEngineId() { } private IModelEngine resolveEngine(String engineId) { - IModelEngine engine = (IModelEngine) Utility.getEngine(engineId); + IEngine engine = Utility.getEngine(engineId); if (engine == null) { throw new IllegalStateException("ModelRouterEngine: could not load engine with id=" + engineId); } - return engine; + if (!(engine instanceof IModelEngine)) { + throw new IllegalStateException("ModelRouterEngine: engine with id=" + engineId + + " is not a model engine (found " + engine.getClass().getName() + ")"); + } + return (IModelEngine) engine; + } + + private static String truncate(String s, int maxLength) { + if (s == null || s.length() <= maxLength) { + return s; + } + return s.substring(0, maxLength) + "..."; } } From 00bdd5cf320fee0a76c7f14a936b3b5186003248 Mon Sep 17 00:00:00 2001 From: Ryan Weiler Date: Mon, 17 Aug 2026 14:36:09 -0400 Subject: [PATCH 3/3] model router engine --- src/prerna/engine/api/IModelRouterEngine.java | 79 ++++ .../engine/impl/model/ModelRouterEngine.java | 391 ++++++++++-------- .../impl/pipeline/EngineProxyFactory.java | 10 +- .../model/GetModelRouterConfigReactor.java | 85 ++++ .../model/UpdateModelRouterConfigReactor.java | 106 +++++ 5 files changed, 495 insertions(+), 176 deletions(-) create mode 100644 src/prerna/engine/api/IModelRouterEngine.java create mode 100644 src/prerna/reactor/model/GetModelRouterConfigReactor.java create mode 100644 src/prerna/reactor/model/UpdateModelRouterConfigReactor.java diff --git a/src/prerna/engine/api/IModelRouterEngine.java b/src/prerna/engine/api/IModelRouterEngine.java new file mode 100644 index 00000000000..36b285ab53e --- /dev/null +++ b/src/prerna/engine/api/IModelRouterEngine.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.engine.api; + +import java.io.IOException; + +import prerna.logging.IgnoreEngineLogging; + +/** + * A model engine that delegates each ask to one of several backing model + * engines based on a routing configuration it can reload at runtime. + * + *

These methods are declared on an interface rather than only on the + * implementation because Utility.getModel returns a dynamic proxy over the + * engine's interfaces (see EngineProxyFactory) - the concrete engine is + * unreachable through it, so a cast to the implementation class fails. Callers + * that need the routing config work against this interface and check + * {@code instanceof IModelRouterEngine} instead of the implementation class. + * + *

All methods are marked {@link IgnoreEngineLogging}: they are admin-time + * configuration operations, not model calls, so they should neither produce + * engine audit rows nor be run through the guardrail pipelines. + */ +public interface IModelRouterEngine extends IModelEngine { + + /** + * Raw contents of the routing config file, for the settings UI. + * + * @return the config file contents + * @throws IOException if the config file is missing or unreadable + */ + @IgnoreEngineLogging + String readConfigJson() throws IOException; + + /** + * Validates the given JSON, persists it to the config file, and applies it to + * the live instance. Nothing is written when validation fails. + * + * @param json the new routing config + * @throws IOException if the config file cannot be written + */ + @IgnoreEngineLogging + void updateConfig(String json) throws IOException; + + /** + * Re-reads and applies the config file on the live instance, picking up an + * edit made outside of {@link #updateConfig(String)}. + * + * @throws IOException if the config file is missing or unreadable + */ + @IgnoreEngineLogging + void reloadConfig() throws IOException; + +} diff --git a/src/prerna/engine/impl/model/ModelRouterEngine.java b/src/prerna/engine/impl/model/ModelRouterEngine.java index ad42eb57e58..6ce32e0afc1 100644 --- a/src/prerna/engine/impl/model/ModelRouterEngine.java +++ b/src/prerna/engine/impl/model/ModelRouterEngine.java @@ -28,10 +28,12 @@ package prerna.engine.impl.model; import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; +import java.io.FileOutputStream; import java.io.IOException; -import java.io.Reader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -50,11 +52,13 @@ import org.apache.logging.log4j.Logger; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; import prerna.auth.User; import prerna.auth.utils.SecurityEngineUtils; import prerna.engine.api.IEngine; import prerna.engine.api.IModelEngine; +import prerna.engine.api.IModelRouterEngine; import prerna.engine.api.ModelTypeEnum; import prerna.engine.impl.model.message.AbstractMessage; import prerna.engine.impl.model.message.InputMessage; @@ -88,8 +92,10 @@ * "classifier_engine": "<engineId>", // required for "llm" mode * "embeddings_engine": "<engineId>", // required for the router to serve embeddings * "routes": [ - * { "name": "code", "engine_id": "aa876e7e-...", "keywords": ["java", "python", "debug"], "weight": 70 }, - * { "name": "sports", "engine_id": "8380e91f-...", "keywords": ["nba", "nfl", "score"], "weight": 30 } + * { "name": "code", "engine_id": "aa876e7e-...", "keywords": ["java", "python", "debug"], + * "description": "Programming questions: debugging, writing and reviewing code", "weight": 70 }, + * { "name": "sports", "engine_id": "8380e91f-...", "keywords": ["nba", "nfl", "score"], + * "description": "Sports questions: scores, players, teams and schedules", "weight": 30 } * ] * } * @@ -98,8 +104,10 @@ *

    *
  • keyword - first route with a whole-word keyword match on the * latest user message wins; otherwise the default route.
  • - *
  • llm - the classifier engine is asked to pick a route by name; - * falls back to keyword matching when classification fails.
  • + *
  • llm - the classifier engine picks a route by reading each route's + * description (required on every route in this mode); falls back to keyword + * matching when classification fails, so keywords are the optional safety + * net here.
  • *
  • weighted - deterministic weighted round-robin across routes with * weight > 0 (weights 30/70 give an exact 30/70 split every cycle).
  • *
@@ -134,8 +142,12 @@ * *

The chosen target is surfaced on the response metadata under the * router_engine_id / routed_engine_id / routed_route_name keys. + * + *

The configuration can be edited at runtime through the + * GetModelRouterConfig / UpdateModelRouterConfig reactors, which read and + * rewrite the config file and then {@link #reloadConfig()} the live instance. */ -public class ModelRouterEngine extends AbstractModelEngine { +public class ModelRouterEngine extends AbstractModelEngine implements IModelRouterEngine { private static final Logger classLogger = LogManager.getLogger(ModelRouterEngine.class); @@ -143,13 +155,6 @@ public class ModelRouterEngine extends AbstractModelEngine { public static final String ROUTER_CONFIG = "ROUTER_CONFIG"; /** Conventional config file name looked up when ROUTER_CONFIG is not set. */ public static final String DEFAULT_CONFIG_FILE = "router.json"; - /** - * Optional SMSS property holding the initial config JSON inline. Engine - * creation from the UI opens the engine before any asset can be uploaded, so - * this is read once to seed the config file when it does not exist yet. The - * file is the source of truth afterwards - later edits belong in the file. - */ - public static final String ROUTER_CONFIG_JSON = "ROUTER_CONFIG_JSON"; /** Response metadata keys describing the routing decision. */ public static final String METADATA_ROUTER_ENGINE_ID = "router_engine_id"; @@ -172,26 +177,29 @@ private static class Route { final String engineId; final List keywords; final int weight; + /** What the LLM classifier reads; required when mode is llm. */ + final String description; /** Whole-word matcher over all keywords; null when the route has none. */ final Pattern keywordPattern; - Route(String name, String engineId, List keywords, int weight) { + Route(String name, String engineId, List keywords, int weight, String description) { this.name = name; this.engineId = engineId; this.keywords = keywords; this.weight = weight; + this.description = description; this.keywordPattern = buildKeywordPattern(keywords); } } - private final List routes = new ArrayList<>(); - private String routingMode = MODE_KEYWORD; - private boolean sticky = true; - private String defaultRouteEngineId; - private final List fallbackEngineIds = new ArrayList<>(); - private String classifierEngineId; - private String embeddingsEngineId; - private int totalWeight = 0; + private volatile List routes = Collections.emptyList(); + private volatile String routingMode = MODE_KEYWORD; + private volatile boolean sticky = true; + private volatile String defaultRouteEngineId; + private volatile List fallbackEngineIds = Collections.emptyList(); + private volatile String classifierEngineId; + private volatile String embeddingsEngineId; + private volatile int totalWeight = 0; /** Round-robin counter for weighted mode - increments on every weighted call. */ private final AtomicInteger rrCounter = new AtomicInteger(0); /** Lazily computed min context window across serving targets; null = not yet computed. */ @@ -215,11 +223,7 @@ public ModelTypeEnum getModelType() { } @Override - public void close() throws IOException { - // ModelRouterEngine holds no resources of its own. The backing engines it - // delegates to are loaded and closed independently by the platform, so there - // is nothing to tear down here. - } + public void close() throws IOException {} /** * Callers sizing work off this engine (e.g. agent auto-compaction) cannot @@ -227,8 +231,8 @@ public void close() throws IOException { * window among the serving targets: routes, default route, and fallbacks. * An explicit CONTEXT_WINDOW in the smss/metadata still wins. Targets that * fail to load or do not report a window are skipped; when none report one, - * 0 is returned and callers treat it as unknown. Computed once on first use, - * so a router reload picks up target changes. + * 0 is returned and callers treat it as unknown. Computed once per config + * (re)load. */ @Override public int getContextWindow() { @@ -264,7 +268,7 @@ private int computeMinTargetContextWindow() { /** Every engine that could serve an ask: routes, default route, fallbacks. */ private List servingEngineIds() { List ids = new ArrayList<>(); - for (Route route : routes) { + for (Route route : this.routes) { addCandidate(ids, route.engineId); } addCandidate(ids, this.defaultRouteEngineId); @@ -275,78 +279,150 @@ private List servingEngineIds() { } // ------------------------------------------------------------------------- - // Lifecycle + // Lifecycle and configuration // ------------------------------------------------------------------------- @Override public void open(Properties smssProp) throws Exception { super.open(smssProp); - - String configFile = this.smssProp.getProperty(ROUTER_CONFIG); - if (configFile == null || configFile.trim().isEmpty()) { - configFile = DEFAULT_CONFIG_FILE; - } - configFile = configFile.trim(); - bootstrapConfigFileIfNeeded(configFile); - loadFromJson(configFile); - validateConfig(configFile); - + reloadConfig(); classLogger.info("ModelRouterEngine '{}' loaded: {} route(s), mode={}, sticky={}", - this.engineId, routes.size(), this.routingMode, this.sticky); + this.engineId, this.routes.size(), this.routingMode, this.sticky); } - private File resolveConfigFile(String configFile) { + /** The config file this router reads: assets/<ROUTER_CONFIG or router.json>. */ + public File resolveConfigFile() { + String configFileName = this.smssProp.getProperty(ROUTER_CONFIG); + if (configFileName == null || configFileName.trim().isEmpty()) { + configFileName = DEFAULT_CONFIG_FILE; + } String assetsFolder = EngineUtility.getSpecificEngineAssetsFolder( IEngine.CATALOG_TYPE.MODEL, this.engineId, this.engineName); - return new File((assetsFolder + "/" + configFile).replace("\\", "/")); + return new File((assetsFolder + "/" + configFileName.trim()).replace("\\", "/")); } - /** - * Seed the config file from the ROUTER_CONFIG_JSON smss property when the - * file does not exist yet. Never overwrites an existing file. - */ - private void bootstrapConfigFileIfNeeded(String configFile) throws IOException { - File jsonFile = resolveConfigFile(configFile); - if (jsonFile.exists()) { - return; - } - String bootstrapJson = this.smssProp.getProperty(ROUTER_CONFIG_JSON); - if (bootstrapJson == null || bootstrapJson.trim().isEmpty()) { - return; - } - File parentFolder = jsonFile.getParentFile(); - if (parentFolder != null && !parentFolder.exists() && !parentFolder.mkdirs()) { - throw new IOException("ModelRouterEngine: could not create assets folder " + parentFolder.getAbsolutePath()); - } - try (FileWriter writer = new FileWriter(jsonFile)) { - writer.write(bootstrapJson.trim()); + /** Raw contents of the config file, for the settings UI. */ + @Override + public String readConfigJson() throws IOException { + File configFile = resolveConfigFile(); + if (!configFile.exists()) { + throw new IOException("ModelRouterEngine: routing config not found at " + configFile.getAbsolutePath()); } - classLogger.info("ModelRouterEngine '{}' seeded {} from the {} smss property", - this.engineId, jsonFile.getName(), ROUTER_CONFIG_JSON); + return new String(Files.readAllBytes(configFile.toPath()), StandardCharsets.UTF_8); } - private void loadFromJson(String configFile) throws IOException { - File jsonFile = resolveConfigFile(configFile); - if (!jsonFile.exists()) { - throw new IOException("ModelRouterEngine: routing config not found at " + jsonFile.getAbsolutePath() + /** + * Re-reads and applies the config file on the live instance. Used at open + * and after {@link #updateConfig(String)} rewrites the file. + */ + @Override + public synchronized void reloadConfig() throws IOException { + File configFile = resolveConfigFile(); + if (!configFile.exists()) { + throw new IOException("ModelRouterEngine: routing config not found at " + configFile.getAbsolutePath() + ". Place a " + DEFAULT_CONFIG_FILE + " in the engine assets folder" + " (or set " + ROUTER_CONFIG + " in the SMSS to use a different file name)."); } + String json = new String(Files.readAllBytes(configFile.toPath()), StandardCharsets.UTF_8); + RouterConfig cfg = parseAndValidateConfig(json, configFile.getName(), this.engineId); + applyConfig(cfg); + } + /** + * Validates the given JSON, persists it to the config file, and applies it + * to the live instance. Nothing is written when validation fails. + */ + @Override + public synchronized void updateConfig(String json) throws IOException { + File configFile = resolveConfigFile(); + RouterConfig cfg = parseAndValidateConfig(json, configFile.getName(), this.engineId); + try (Writer writer = new OutputStreamWriter(new FileOutputStream(configFile), StandardCharsets.UTF_8)) { + writer.write(json); + } + applyConfig(cfg); + classLogger.info("ModelRouterEngine '{}' config updated: {} route(s), mode={}, sticky={}", + this.engineId, this.routes.size(), this.routingMode, this.sticky); + } + + /** + * Parses and validates router config JSON without touching any engine + * state, so both engine open and the update reactor share one set of + * rules. Fails on configurations that would otherwise silently degrade at + * request time (unknown mode, llm without a classifier or descriptions, + * weighted without weights, a route pointing back at the router). + * + * @param json the raw config JSON + * @param configName file/source name used in error messages + * @param routerEngineId the router's own engine id, for self-reference checks + * @return the parsed config, safe to apply + */ + public static RouterConfig parseAndValidateConfig(String json, String configName, String routerEngineId) { RouterConfig cfg; - try (Reader reader = new FileReader(jsonFile)) { - cfg = new Gson().fromJson(reader, RouterConfig.class); + try { + cfg = new Gson().fromJson(json, RouterConfig.class); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("ModelRouterEngine: " + configName + " is not valid JSON - " + e.getMessage(), e); } if (cfg == null || cfg.routes == null || cfg.routes.isEmpty()) { - throw new IllegalArgumentException("ModelRouterEngine: " + configFile + " must define at least one route"); + throw new IllegalArgumentException("ModelRouterEngine: " + configName + " must define at least one route"); + } + + String mode = resolvedMode(cfg); + if (!MODE_KEYWORD.equals(mode) && !MODE_LLM.equals(mode) && !MODE_WEIGHTED.equals(mode)) { + throw new IllegalArgumentException("ModelRouterEngine: unknown mode '" + mode + "' in " + configName + + ". Valid modes are: " + MODE_KEYWORD + ", " + MODE_LLM + ", " + MODE_WEIGHTED); } + Set seenNames = new HashSet<>(); + int totalWeight = 0; + boolean anyKeywords = false; for (int i = 0; i < cfg.routes.size(); i++) { RouteConfig rc = cfg.routes.get(i); if (rc.engine_id == null || rc.engine_id.trim().isEmpty()) { - throw new IllegalArgumentException("ModelRouterEngine: route " + i + " in " + configFile + " is missing engine_id"); + throw new IllegalArgumentException("ModelRouterEngine: route " + i + " in " + configName + " is missing engine_id"); } - String name = (rc.name != null && !rc.name.trim().isEmpty()) ? rc.name.trim() : ("ROUTE_" + i); + String name = resolvedRouteName(rc, i); + if (!seenNames.add(name.toLowerCase())) { + throw new IllegalArgumentException("ModelRouterEngine: duplicate route name '" + name + "' in " + configName); + } + rejectSelfReference(rc.engine_id.trim(), "route '" + name + "'", configName, routerEngineId); + totalWeight += Math.max(0, rc.weight); + anyKeywords = anyKeywords || (rc.keywords != null && !rc.keywords.isEmpty()); + + if (MODE_LLM.equals(mode) && trimOrNull(rc.description) == null) { + throw new IllegalArgumentException("ModelRouterEngine: mode 'llm' requires a description on every route - route '" + + name + "' in " + configName + " is missing one"); + } + } + + if (MODE_LLM.equals(mode) && trimOrNull(cfg.classifier_engine) == null) { + throw new IllegalArgumentException("ModelRouterEngine: mode 'llm' requires classifier_engine in " + configName); + } + if (MODE_WEIGHTED.equals(mode) && totalWeight <= 0) { + throw new IllegalArgumentException("ModelRouterEngine: mode 'weighted' requires at least one route with weight > 0 in " + configName); + } + if (MODE_KEYWORD.equals(mode) && !anyKeywords) { + classLogger.warn("ModelRouterEngine ({}): mode is 'keyword' but no route defines keywords - every request will use the default route", + configName); + } + + rejectSelfReference(trimOrNull(cfg.default_route), "default_route", configName, routerEngineId); + rejectSelfReference(trimOrNull(cfg.classifier_engine), "classifier_engine", configName, routerEngineId); + rejectSelfReference(trimOrNull(cfg.embeddings_engine), "embeddings_engine", configName, routerEngineId); + if (cfg.fallbacks != null) { + for (String fallback : cfg.fallbacks) { + rejectSelfReference(trimOrNull(fallback), "fallbacks", configName, routerEngineId); + } + } + + return cfg; + } + + /** Maps a validated config onto this instance and resets routing state. */ + private void applyConfig(RouterConfig cfg) { + List newRoutes = new ArrayList<>(); + for (int i = 0; i < cfg.routes.size(); i++) { + RouteConfig rc = cfg.routes.get(i); List keywords = new ArrayList<>(); if (rc.keywords != null) { for (String kw : rc.keywords) { @@ -355,83 +431,51 @@ private void loadFromJson(String configFile) throws IOException { } } } - routes.add(new Route(name, rc.engine_id.trim(), keywords, Math.max(0, rc.weight))); + newRoutes.add(new Route(resolvedRouteName(rc, i), rc.engine_id.trim(), + Collections.unmodifiableList(keywords), Math.max(0, rc.weight), trimOrNull(rc.description))); } - this.defaultRouteEngineId = trimOrNull(cfg.default_route); - this.classifierEngineId = trimOrNull(cfg.classifier_engine); - this.embeddingsEngineId = trimOrNull(cfg.embeddings_engine); - if (cfg.mode != null && !cfg.mode.trim().isEmpty()) { - this.routingMode = cfg.mode.trim().toLowerCase(); - } - if (cfg.sticky != null) { - this.sticky = cfg.sticky.booleanValue(); - } + List newFallbacks = new ArrayList<>(); if (cfg.fallbacks != null) { - for (String fb : cfg.fallbacks) { - String trimmed = trimOrNull(fb); + for (String fallback : cfg.fallbacks) { + String trimmed = trimOrNull(fallback); if (trimmed != null) { - this.fallbackEngineIds.add(trimmed); + newFallbacks.add(trimmed); } } } - } - /** - * Fail engine open on configurations that would otherwise silently degrade - * at request time (unknown mode, llm without a classifier, weighted without - * weights, a route pointing back at this router). - */ - private void validateConfig(String configFile) { - if (!MODE_KEYWORD.equals(this.routingMode) - && !MODE_LLM.equals(this.routingMode) - && !MODE_WEIGHTED.equals(this.routingMode)) { - throw new IllegalArgumentException("ModelRouterEngine: unknown mode '" + this.routingMode - + "' in " + configFile + ". Valid modes are: " - + MODE_KEYWORD + ", " + MODE_LLM + ", " + MODE_WEIGHTED); + int newTotalWeight = 0; + for (Route route : newRoutes) { + newTotalWeight += route.weight; } - if (MODE_LLM.equals(this.routingMode) && this.classifierEngineId == null) { - throw new IllegalArgumentException("ModelRouterEngine: mode 'llm' requires classifier_engine in " + configFile); - } + this.routes = Collections.unmodifiableList(newRoutes); + this.fallbackEngineIds = Collections.unmodifiableList(newFallbacks); + this.routingMode = resolvedMode(cfg); + this.sticky = cfg.sticky == null || cfg.sticky.booleanValue(); + this.defaultRouteEngineId = trimOrNull(cfg.default_route); + this.classifierEngineId = trimOrNull(cfg.classifier_engine); + this.embeddingsEngineId = trimOrNull(cfg.embeddings_engine); + this.totalWeight = newTotalWeight; - for (Route r : routes) { - this.totalWeight += r.weight; - } - if (MODE_WEIGHTED.equals(this.routingMode) && this.totalWeight <= 0) { - throw new IllegalArgumentException("ModelRouterEngine: mode 'weighted' requires at least one route with weight > 0 in " + configFile); - } + this.rrCounter.set(0); + this.roomRoutePins.clear(); + this.derivedContextWindow = null; + } - Set seenNames = new HashSet<>(); - for (Route r : routes) { - if (!seenNames.add(r.name.toLowerCase())) { - throw new IllegalArgumentException("ModelRouterEngine: duplicate route name '" + r.name + "' in " + configFile); - } - rejectSelfReference(r.engineId, "route '" + r.name + "'", configFile); - } - rejectSelfReference(this.defaultRouteEngineId, "default_route", configFile); - rejectSelfReference(this.classifierEngineId, "classifier_engine", configFile); - rejectSelfReference(this.embeddingsEngineId, "embeddings_engine", configFile); - for (String fb : this.fallbackEngineIds) { - rejectSelfReference(fb, "fallbacks", configFile); - } + private static String resolvedMode(RouterConfig cfg) { + return (cfg.mode != null && !cfg.mode.trim().isEmpty()) ? cfg.mode.trim().toLowerCase() : MODE_KEYWORD; + } - if (MODE_KEYWORD.equals(this.routingMode)) { - boolean anyKeywords = false; - for (Route r : routes) { - anyKeywords = anyKeywords || !r.keywords.isEmpty(); - } - if (!anyKeywords) { - classLogger.warn("ModelRouterEngine '{}': mode is 'keyword' but no route defines keywords - every request will use the default route", - this.engineId); - } - } + private static String resolvedRouteName(RouteConfig rc, int index) { + return (rc.name != null && !rc.name.trim().isEmpty()) ? rc.name.trim() : ("ROUTE_" + index); } - private void rejectSelfReference(String engineId, String field, String configFile) { - if (engineId != null && engineId.equals(this.engineId)) { - throw new IllegalArgumentException("ModelRouterEngine: " + field + " in " + configFile - + " points back at this router (" + this.engineId + ") - this would recurse forever"); + private static void rejectSelfReference(String engineId, String field, String configName, String routerEngineId) { + if (engineId != null && engineId.equals(routerEngineId)) { + throw new IllegalArgumentException("ModelRouterEngine: " + field + " in " + configName + + " points back at this router (" + routerEngineId + ") - this would recurse forever"); } } @@ -459,7 +503,7 @@ private static Pattern buildKeywordPattern(List keywords) { } /** Gson DTO for the router JSON file. Field names must match JSON keys. */ - private static class RouterConfig { + public static class RouterConfig { String mode; Boolean sticky; String default_route; @@ -469,11 +513,12 @@ private static class RouterConfig { List routes; } - private static class RouteConfig { + public static class RouteConfig { String name; String engine_id; int weight; List keywords; + String description; } // ------------------------------------------------------------------------- @@ -536,13 +581,6 @@ protected AskModelEngineResponse askCall(String question, Object fullPrompt, Str classLogger.info("ModelRouterEngine '{}' routing room {} to engineId={}", this.engineId, roomId, candidateId); try { - // Delegate straight to the target's askRoom with the caller's room and a - // fresh copy of the parameters, so message_json/tools pass through - // verbatim, provider-specific mutations from a failed attempt do not leak - // into the next one, and the target's inference log lands under the real - // room id. Room.ask is skipped on purpose: it would rebuild message_json - // from scratch and overwrite the room context in the DB when a system - // prompt is present. Map params = hyperParameters != null ? new HashMap<>(hyperParameters) : new HashMap<>(); @@ -564,7 +602,6 @@ protected AskModelEngineResponse askCall(String question, Object fullPrompt, Str this.engineId, candidateId, e); lastFailure = e; if (this.sticky && roomId != null) { - // drop the pin so the next turn re-selects instead of retrying a dead engine roomRoutePins.remove(roomId); } } @@ -583,16 +620,17 @@ protected AskModelEngineResponse askCall(String question, Object fullPrompt, Str protected EmbeddingsModelEngineResponse embeddingsCall(List stringsToEmbed, Insight insight, Map parameters) { - if (this.embeddingsEngineId == null) { + String engId = this.embeddingsEngineId; + if (engId == null) { throw new IllegalStateException("ModelRouterEngine '" + this.engineId + "': no embeddings_engine configured in the router config - this router cannot serve embeddings"); } User user = insight != null ? insight.getUser() : null; - if (!userCanUseTarget(user, this.embeddingsEngineId)) { + if (!userCanUseTarget(user, engId)) { throw new IllegalStateException("ModelRouterEngine '" + this.engineId - + "': user does not have access to the embeddings engine " + this.embeddingsEngineId); + + "': user does not have access to the embeddings engine " + engId); } - IModelEngine targetEngine = resolveEngine(this.embeddingsEngineId); + IModelEngine targetEngine = resolveEngine(engId); return targetEngine.embeddings(stringsToEmbed, insight, parameters); } @@ -647,20 +685,21 @@ private String selectRoute(String routingText, Insight insight, Room room) { * repeating exactly, so the split is exact over every full cycle. */ private String selectRouteByWeight() { - if (this.totalWeight <= 0) { + final int total = this.totalWeight; + if (total <= 0) { return fallbackEngineId(); } // Atomically grab the next position in the cycle and wrap at total - int pos = rrCounter.getAndUpdate(c -> (c + 1) % this.totalWeight); + int pos = rrCounter.getAndUpdate(c -> (c + 1) % total); int cumulative = 0; - for (Route r : routes) { + for (Route r : this.routes) { if (r.weight <= 0) { continue; } cumulative += r.weight; if (pos < cumulative) { classLogger.info("ModelRouterEngine round-robin pos {}/{} -> route '{}' (weight {})", - pos, this.totalWeight, r.name, r.weight); + pos, total, r.name, r.weight); return r.engineId; } } @@ -672,7 +711,7 @@ private String selectRouteByWeight() { * in the routing text. Falls back to the default engine. */ private String selectRouteByKeyword(String routingText) { - for (Route route : routes) { + for (Route route : this.routes) { if (route.keywordPattern == null) { continue; } @@ -694,9 +733,12 @@ private String selectRouteByKeyword(String routingText) { private String selectRouteByLLM(String routingText, Insight insight, Room room) { try { StringBuilder routeList = new StringBuilder(); - for (Route r : routes) { + for (Route r : this.routes) { routeList.append("- ").append(r.name); - if (!r.keywords.isEmpty()) { + if (r.description != null) { + routeList.append(": ").append(r.description); + } else if (!r.keywords.isEmpty()) { + // defensive only - llm mode validates descriptions at open routeList.append(" (for questions about: ") .append(String.join(", ", r.keywords)) .append(")"); @@ -713,9 +755,6 @@ private String selectRouteByLLM(String routingText, Insight insight, Room room) IModelEngine classifierEngine = resolveEngine(this.classifierEngineId); - // One-shot call against the caller's room and insight: askRoom is invoked - // directly (never Room.ask) so nothing is appended to the room history and - // no per-request classification rooms are written to the logs database. Map params = new HashMap<>(); InputMessage msg = InputMessage.builder(room) .withText(classificationPrompt) @@ -728,7 +767,7 @@ private String selectRouteByLLM(String routingText, Insight insight, Room room) String routeName = response.getStringResponse(); routeName = routeName != null ? routeName.trim() : ""; - for (Route route : routes) { + for (Route route : this.routes) { if (route.name.equalsIgnoreCase(routeName)) { classLogger.info("ModelRouterEngine: LLM classified question as route '{}'", routeName); return route.engineId; @@ -752,13 +791,15 @@ private String selectRouteByLLM(String routingText, Insight insight, Room room) private List buildCandidateList(String primaryEngineId) { List candidates = new ArrayList<>(); addCandidate(candidates, primaryEngineId); - for (String fb : this.fallbackEngineIds) { - addCandidate(candidates, fb); + for (String fallback : this.fallbackEngineIds) { + addCandidate(candidates, fallback); } - if (this.defaultRouteEngineId != null) { - addCandidate(candidates, this.defaultRouteEngineId); - } else if (!routes.isEmpty()) { - addCandidate(candidates, routes.get(0).engineId); + String defaultRoute = this.defaultRouteEngineId; + List currentRoutes = this.routes; + if (defaultRoute != null) { + addCandidate(candidates, defaultRoute); + } else if (!currentRoutes.isEmpty()) { + addCandidate(candidates, currentRoutes.get(0).engineId); } return candidates; } @@ -806,7 +847,7 @@ private void attachRouteMetadata(AskModelEngineResponse response, String targ } private String routeNameForEngine(String engineId) { - for (Route r : routes) { + for (Route r : this.routes) { if (r.engineId.equals(engineId)) { return r.name; } @@ -815,11 +856,13 @@ private String routeNameForEngine(String engineId) { } private String fallbackEngineId() { - if (this.defaultRouteEngineId != null) { - return this.defaultRouteEngineId; + String defaultRoute = this.defaultRouteEngineId; + if (defaultRoute != null) { + return defaultRoute; } - if (!routes.isEmpty()) { - return routes.get(0).engineId; + List currentRoutes = this.routes; + if (!currentRoutes.isEmpty()) { + return currentRoutes.get(0).engineId; } throw new IllegalStateException("ModelRouterEngine: no routes configured and no default engine set"); } diff --git a/src/prerna/engine/impl/pipeline/EngineProxyFactory.java b/src/prerna/engine/impl/pipeline/EngineProxyFactory.java index cad9503e354..3da602d07dd 100644 --- a/src/prerna/engine/impl/pipeline/EngineProxyFactory.java +++ b/src/prerna/engine/impl/pipeline/EngineProxyFactory.java @@ -39,6 +39,7 @@ import prerna.engine.api.IFunctionEngine; import prerna.engine.api.IGuardrailReactorFunctionEngine; import prerna.engine.api.IModelEngine; +import prerna.engine.api.IModelRouterEngine; import prerna.engine.api.IRCloneStorage; import prerna.engine.api.IRDBMSEngine; import prerna.engine.api.IRDFDatabase; @@ -74,8 +75,13 @@ public static IModelEngine createGuardedModelEngine(IModelEngine engine) { } PipelineInvocationHandler handler = new PipelineInvocationHandler(engine, jsonFile); - return (IModelEngine) Proxy.newProxyInstance(IEngine.class.getClassLoader(), - new Class[] { IEngine.class, IModelEngine.class }, handler); + Class[] classes = null; + if (engine instanceof IModelRouterEngine) { + classes = new Class[] { IEngine.class, IModelEngine.class, IModelRouterEngine.class }; + } else { + classes = new Class[] { IEngine.class, IModelEngine.class }; + } + return (IModelEngine) Proxy.newProxyInstance(IEngine.class.getClassLoader(), classes, handler); } /** diff --git a/src/prerna/reactor/model/GetModelRouterConfigReactor.java b/src/prerna/reactor/model/GetModelRouterConfigReactor.java new file mode 100644 index 00000000000..accf7367c38 --- /dev/null +++ b/src/prerna/reactor/model/GetModelRouterConfigReactor.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.model; + +import java.io.IOException; + +import prerna.auth.utils.SecurityEngineUtils; +import prerna.engine.api.IModelEngine; +import prerna.engine.api.IModelRouterEngine; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.ReactorKeysEnum; +import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.util.Utility; + +/** + * Returns the raw router.json contents for a MODEL_ROUTER engine so the + * settings UI can load the current routing configuration. Requires edit + * access - the config exposes the engine ids of every routing target. + */ +public class GetModelRouterConfigReactor extends AbstractReactor { + + public GetModelRouterConfigReactor() { + this.keysToGet = new String[] { ReactorKeysEnum.ENGINE.getKey() }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + String engineId = this.keyValue.get(this.keysToGet[0]); + if (!SecurityEngineUtils.userCanEditEngine(this.insight.getUser(), engineId)) { + throw new IllegalArgumentException("Engine " + engineId + " does not exist or user does not have edit access to it"); + } + + IModelEngine model = Utility.getModel(engineId); + if (!(model instanceof IModelRouterEngine)) { + throw new IllegalArgumentException("Engine " + engineId + " is not a model router"); + } + + try { + String configJson = ((IModelRouterEngine) model).readConfigJson(); + return new NounMetadata(configJson, PixelDataType.CONST_STRING); + } catch (IOException e) { + throw new IllegalStateException("Unable to read the router configuration: " + e.getMessage(), e); + } + } + + @Override + public String getReactorDescription() { + return "Returns the routing configuration (router.json contents) for a model router engine. Requires edit access to the engine."; + } + + @Override + protected String getDescriptionForKey(String key) { + if (key.equals(ReactorKeysEnum.ENGINE.getKey())) { + return "The id of the model router engine"; + } + return super.getDescriptionForKey(key); + } +} diff --git a/src/prerna/reactor/model/UpdateModelRouterConfigReactor.java b/src/prerna/reactor/model/UpdateModelRouterConfigReactor.java new file mode 100644 index 00000000000..423bd4f3a6b --- /dev/null +++ b/src/prerna/reactor/model/UpdateModelRouterConfigReactor.java @@ -0,0 +1,106 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.model; + +import java.io.IOException; +import java.util.Map; + +import com.google.gson.GsonBuilder; + +import prerna.auth.utils.SecurityEngineUtils; +import prerna.cluster.util.ClusterUtil; +import prerna.engine.api.IModelEngine; +import prerna.engine.api.IModelRouterEngine; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.ReactorKeysEnum; +import prerna.sablecc2.om.nounmeta.NounMetadata; +import prerna.util.Utility; + +/** + * Rewrites a MODEL_ROUTER engine's router.json from the settings UI. The + * config is validated with the same rules engine open uses before anything is + * written, and the live engine instance applies the new routing immediately - + * no engine reload required. + */ +public class UpdateModelRouterConfigReactor extends AbstractReactor { + + public UpdateModelRouterConfigReactor() { + this.keysToGet = new String[] { ReactorKeysEnum.ENGINE.getKey(), ReactorKeysEnum.MAP.getKey() }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + String engineId = this.keyValue.get(this.keysToGet[0]); + if (!SecurityEngineUtils.userCanEditEngine(this.insight.getUser(), engineId)) { + throw new IllegalArgumentException("Engine " + engineId + " does not exist or user does not have edit access to it"); + } + + IModelEngine model = Utility.getModel(engineId); + if (!(model instanceof IModelRouterEngine)) { + throw new IllegalArgumentException("Engine " + engineId + " is not a model router"); + } + + Map config = this.getGenericMap(ReactorKeysEnum.MAP.getKey(), null); + if (config == null || config.isEmpty()) { + throw new IllegalArgumentException("Must provide the routing configuration map"); + } + + String json = new GsonBuilder().disableHtmlEscaping().create().toJson(config); + try { + // validates first and writes nothing when validation fails + ((IModelRouterEngine) model).updateConfig(json); + } catch (IOException e) { + throw new IllegalStateException("Unable to write the router configuration: " + e.getMessage(), e); + } + + if (ClusterUtil.IS_CLUSTER) { + ClusterUtil.pushEngine(engineId); + } + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } + + @Override + public String getReactorDescription() { + return "Validates and saves the routing configuration (router.json) for a model router engine, applying it to the running engine immediately. Requires edit access to the engine."; + } + + @Override + protected String getDescriptionForKey(String key) { + if (key.equals(ReactorKeysEnum.ENGINE.getKey())) { + return "The id of the model router engine"; + } + if (key.equals(ReactorKeysEnum.MAP.getKey())) { + return "The routing configuration as a map matching the router.json schema"; + } + return super.getDescriptionForKey(key); + } +}