';
+ exit;
+}
+
+$dashboardPage = 'llm-engines';
+$dashboardTitle = 'LLM Engines';
+$dashboardLead = 'Remap which model each tool/tier uses — live, no code push. Overrides fall back to the code/.env default when cleared.';
+require_once __DIR__ . '/../includes/layout_dashboard.php';
+?>
+
+
+
Routing context
+
+
+
+
+
Loading…
+
+
+
+
+
Tier engines (Quick / Pro)
+
+
+ The * rows set the default for every tier tool. A per-tool row overrides just that tool. Credits stay tied to the tier, not the chosen engine.
+
+
+
+
+
+
+
Reviewer personas (legal-analysis track)
+
+
+
+
+
+
+
+
+
diff --git a/includes/ToolModels.php b/includes/ToolModels.php
index 5954d8e..369ff4b 100644
--- a/includes/ToolModels.php
+++ b/includes/ToolModels.php
@@ -91,6 +91,22 @@ final class ToolModels
return self::QUALITY_TIERS[self::qualityTier($tier)]['engine'];
}
+ /**
+ * Engine keys the gateway layer can route for the tier/legacy tools. This is the
+ * allowlist an operator override (dbn_tool_engine_config) is validated against, so a
+ * bad DB value can never produce an unroutable engine string.
+ */
+ public static function tierEngineKeys(): array
+ {
+ return ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex', 'claude_haiku', 'claude_sonnet'];
+ }
+
+ /** True if $engine is a routable tier/legacy engine key. */
+ public static function isValidTierEngine(string $engine): bool
+ {
+ return in_array($engine, self::tierEngineKeys(), true);
+ }
+
/** Human label for a quality tier (UI / telemetry). */
public static function tierLabel(string $tier): string
{
diff --git a/includes/bootstrap.php b/includes/bootstrap.php
index 0e5e209..a841afc 100644
--- a/includes/bootstrap.php
+++ b/includes/bootstrap.php
@@ -180,6 +180,64 @@ function dbnToolsAuthenticatedUser(): ?array
];
}
+/**
+ * Operator gate for the LLM-engine admin. True when the authenticated session is the
+ * tenant owner, or when their email is listed in the DBN_ADMIN_EMAILS allowlist (so an
+ * SSO operator can self-authorise without an 'owner' client_users row).
+ */
+function dbnToolsIsOwner(): bool
+{
+ if (!dbnToolsIsAuthenticated()) {
+ return false;
+ }
+ if (strtolower((string)($_SESSION['dbn_tools_user_role'] ?? '')) === 'owner') {
+ return true;
+ }
+ $email = strtolower(trim((string)($_SESSION['dbn_tools_user_email'] ?? $_SESSION['dbn_tools_sso_email'] ?? '')));
+ if ($email === '') {
+ return false;
+ }
+ $allow = array_filter(array_map('trim', explode(',', strtolower((string)(dbnToolsEnv('DBN_ADMIN_EMAILS', '') ?? '')))));
+ return in_array($email, $allow, true);
+}
+
+/**
+ * Look up an operator engine override for a tool + scope from dbn_tool_engine_config
+ * (dobetternorge_maindb). Prefers a tool-specific row, then a '*' all-tools row.
+ * Returns null when no enabled override exists. Statically cached per request; safe
+ * (returns null) when the table is absent, so behaviour is unchanged pre-migration.
+ *
+ * scope: tier_quick | tier_pro | legacy | persona_family | persona_general
+ */
+function dbnToolsEngineOverride(string $tool, string $scope): ?string
+{
+ static $cache = null;
+ if ($cache === null) {
+ $cache = [];
+ try {
+ $rows = dbnmDb()->query('SELECT tool_slug, scope, engine FROM dbn_tool_engine_config WHERE enabled = 1')->fetchAll();
+ foreach ($rows as $r) {
+ $cache[$r['tool_slug'] . '|' . $r['scope']] = (string)$r['engine'];
+ }
+ } catch (Throwable $e) {
+ $cache = []; // table missing / DB down → no overrides
+ }
+ }
+ return $cache[$tool . '|' . $scope] ?? $cache['*|' . $scope] ?? null;
+}
+
+/** Reviewer/persona model ids an operator override may select (validated against this). */
+function dbnToolsReviewerModelKeys(): array
+{
+ return ['gpt-4o', 'gpt-4o-mini', 'dbn-legal-agent-v3', 'dbn-legal-agent', 'dobetter-norge-v4', 'qwen2.5:14b'];
+}
+
+/** True if $model is an accepted reviewer/persona model id. */
+function dbnToolsIsValidReviewerModel(string $model): bool
+{
+ return in_array($model, dbnToolsReviewerModelKeys(), true);
+}
+
function dbnToolsRequiredPackageSlug(): string
{
return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
@@ -506,9 +564,17 @@ function dbnToolsPersonaTrack(?string $slug): string
function dbnToolsReviewerModel(?string $slug = null): string
{
if (dbnToolsPersonaTrack($slug) === 'family') {
+ $override = dbnToolsEngineOverride('*', 'persona_family');
+ if ($override !== null && dbnToolsIsValidReviewerModel($override)) {
+ return $override;
+ }
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? 'dbn-legal-agent-v3'))
?: 'dbn-legal-agent-v3';
}
+ $override = dbnToolsEngineOverride('*', 'persona_general');
+ if ($override !== null && dbnToolsIsValidReviewerModel($override)) {
+ return $override;
+ }
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_GENERAL', 'gpt-4o') ?? 'gpt-4o')) ?: 'gpt-4o';
}
@@ -790,6 +856,11 @@ function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaul
{
if (isset($input['tier'])) {
$res = ToolModels::resolveTier(dbnToolsFreeTierUid(), $tool, (string)$input['tier']);
+ $scope = $res['tier'] === 'pro' ? 'tier_pro' : 'tier_quick';
+ $override = dbnToolsEngineOverride($tool, $scope);
+ if ($override !== null && ToolModels::isValidTierEngine($override)) {
+ $res['engine'] = $override; // credits stay tied to the tier; only the engine swaps
+ }
$ftUid = dbnToolsFreeTierCheckAmount($tool, $res['credits']);
return [
'tier' => $res['tier'],
@@ -800,8 +871,13 @@ function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaul
];
}
- $ftUid = dbnToolsFreeTierCheck($tool);
- $engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? $legacyDefaultEngine));
+ $ftUid = dbnToolsFreeTierCheck($tool);
+ $requested = (string)($input['engine'] ?? $legacyDefaultEngine);
+ $override = dbnToolsEngineOverride($tool, 'legacy');
+ if ($override !== null && ToolModels::isValidTierEngine($override)) {
+ $requested = $override; // still passes through engineForUser tier gating below
+ }
+ $engine = ToolModels::engineForUser($ftUid, $requested);
return [
'tier' => '',
'engine' => $engine,
diff --git a/includes/layout_dashboard.php b/includes/layout_dashboard.php
index ef673e0..ed75b42 100644
--- a/includes/layout_dashboard.php
+++ b/includes/layout_dashboard.php
@@ -51,6 +51,9 @@ $dashboardNav = [
'trash' => ['url' => '/dashboard/trash.php', 'label' => dbnToolsT('dash_nav_trash', $uiLang) ?: 'Trash', 'sub' => 'Restore or purge'],
'settings' => ['url' => '/dashboard/settings.php', 'label' => dbnToolsT('dash_nav_settings', $uiLang), 'sub' => 'Settings'],
];
+if (dbnToolsIsOwner()) {
+ $dashboardNav['llm-engines'] = ['url' => '/dashboard/llm-engines.php', 'label' => 'LLM Engines', 'sub' => 'Model routing (owner)'];
+}
?>
diff --git a/migrations/maindb/001_tool_engine_config.sql b/migrations/maindb/001_tool_engine_config.sql
new file mode 100644
index 0000000..b749d04
--- /dev/null
+++ b/migrations/maindb/001_tool_engine_config.sql
@@ -0,0 +1,27 @@
+-- 001_tool_engine_config.sql
+-- Target DB: dobetternorge_maindb (the tools' operational DB, via dbnmDb()).
+-- Run manually after a mysqldump backup. Idempotent (IF NOT EXISTS).
+--
+-- DB-backed engine overrides for the legal tools. When a row exists and is
+-- enabled, the matching resolver (dbnToolsResolveToolRun / dbnToolsReviewerModel)
+-- uses its `engine` instead of the code/.env default. No row = unchanged behaviour.
+--
+-- tool_slug : a tool slug (ask|summarize|deep-research|korrespond|barnevernet|
+-- discrepancy|legal-analysis...) OR '*' for an all-tools default.
+-- scope : tier_quick | tier_pro | legacy | persona_family | persona_general
+-- engine : tier/legacy scopes -> a ToolModels engine key (claude_haiku,
+-- claude_sonnet, azure_mini, azure_full, gpu, nova_lite, regex);
+-- persona scopes -> a reviewer model id (gpt-4o, gpt-4o-mini,
+-- dbn-legal-agent-v3, dobetter-norge-v4, ...). Validated in code.
+
+CREATE TABLE IF NOT EXISTS dbn_tool_engine_config (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ tool_slug VARCHAR(64) NOT NULL,
+ scope VARCHAR(32) NOT NULL,
+ engine VARCHAR(64) NOT NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ updated_by VARCHAR(190) DEFAULT NULL,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_tool_scope (tool_slug, scope)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;