diff --git a/api/dashboard/engines.php b/api/dashboard/engines.php new file mode 100644 index 0000000..1811d37 --- /dev/null +++ b/api/dashboard/engines.php @@ -0,0 +1,213 @@ + 'Claude Haiku 4.5', + 'claude_sonnet' => 'Claude Sonnet 4.6', + 'azure_mini' => 'Azure GPT-4o-mini', + 'azure_full' => 'Azure GPT-4o', + 'gpu' => 'GPU Qwen 2.5 14B', + 'nova_lite' => 'Nova Lite', + 'regex' => 'Regex only', + ][$key] ?? $key; +} + +function enginesReviewerLabel(string $key): string +{ + return [ + 'gpt-4o' => 'GPT-4o (Azure)', + 'gpt-4o-mini' => 'GPT-4o-mini (Azure)', + 'dbn-legal-agent-v3' => 'DBN Legal v3 (GPU fine-tune)', + 'dbn-legal-agent' => 'DBN Legal (GPU)', + 'dobetter-norge-v4' => 'Do Better Norge v4 (GPU)', + 'qwen2.5:14b' => 'Qwen 2.5 14B', + ][$key] ?? $key; +} + +/** Code/.env default engine for a given scope (what applies when no override is set). */ +function enginesCodeDefault(string $scope): string +{ + switch ($scope) { + case 'tier_quick': return ToolModels::tierEngine('quick'); + case 'tier_pro': return ToolModels::tierEngine('pro'); + case 'legacy': return 'azure_mini'; + case 'persona_family': + return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? '')) ?: 'dbn-legal-agent-v3'; + case 'persona_general': + return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_GENERAL', 'gpt-4o') ?? '')) ?: 'gpt-4o'; + } + return ''; +} + +function enginesValidScope(string $scope): bool +{ + return in_array($scope, array_merge(ENGINES_TIER_SCOPES, ENGINES_PERSONA_SCOPES), true); +} + +function enginesValidEngineForScope(string $scope, string $engine): bool +{ + if (in_array($scope, ENGINES_PERSONA_SCOPES, true)) { + return dbnToolsIsValidReviewerModel($engine); + } + return ToolModels::isValidTierEngine($engine); +} + +$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); + +try { + if ($method === 'GET') { + enginesGet(); + } else { + $action = (string)($_GET['action'] ?? ''); + if ($action === 'set') { + enginesSet(); + } elseif ($action === 'clear') { + enginesClear(); + } else { + dbnToolsError('Unknown action.', 400, 'unknown_action'); + } + } +} catch (DbnToolsHttpException $e) { + dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra ?? []); +} catch (Throwable $e) { + error_log('[dbn-engines] ' . $e->getMessage()); + dbnToolsError('Engine admin operation failed.', 500, 'op_failed'); +} + +function enginesLoadOverrides(): array +{ + $map = []; + try { + $rows = dbnmDb()->query('SELECT tool_slug, scope, engine, updated_by, updated_at FROM dbn_tool_engine_config WHERE enabled = 1')->fetchAll(); + foreach ($rows as $r) { + $map[$r['tool_slug'] . '|' . $r['scope']] = $r; + } + } catch (Throwable $e) { + // table not yet migrated → no overrides + } + return $map; +} + +function enginesGet(): void +{ + $overrides = enginesLoadOverrides(); + + $tierEngines = array_map( + static fn(string $k): array => ['key' => $k, 'label' => enginesTierLabel($k)], + ToolModels::tierEngineKeys() + ); + $reviewerModels = array_map( + static fn(string $k): array => ['key' => $k, 'label' => enginesReviewerLabel($k)], + dbnToolsReviewerModelKeys() + ); + + $rows = []; + $row = static function (string $tool, string $scope) use ($overrides): array { + $key = $tool . '|' . $scope; + $ov = $overrides[$key]['engine'] ?? null; + $def = enginesCodeDefault($scope); + return [ + 'tool_slug' => $tool, + 'scope' => $scope, + 'code_default' => $def, + 'override' => $ov, + 'effective' => $ov ?? $def, + 'updated_by' => $overrides[$key]['updated_by'] ?? null, + 'updated_at' => $overrides[$key]['updated_at'] ?? null, + ]; + }; + + // Global tier defaults (apply to every tier tool unless a per-tool row overrides). + $rows[] = $row('*', 'tier_quick'); + $rows[] = $row('*', 'tier_pro'); + // Per-tool tier overrides. + foreach (ENGINES_TIER_TOOLS as $tool) { + $rows[] = $row($tool, 'tier_quick'); + $rows[] = $row($tool, 'tier_pro'); + } + // Persona reviewer (legal-analysis track). + $rows[] = $row('*', 'persona_family'); + $rows[] = $row('*', 'persona_general'); + + dbnToolsRespond([ + 'ok' => true, + 'context' => [ + 'bedrock_enabled' => filter_var(dbnToolsEnv('DBN_BEDROCK_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN), + 'note' => 'claude_* engines route to AWS Bedrock when DBN_BEDROCK_ENABLED=true, else fall back to Azure GPT. GPU fine-tunes degrade to gpt-4o when the pod is offline.', + ], + 'tier_engines' => $tierEngines, + 'reviewer_models' => $reviewerModels, + 'tier_tools' => ENGINES_TIER_TOOLS, + 'rows' => $rows, + ]); +} + +function enginesSet(): void +{ + $input = dbnToolsJsonInput(2_000); + $tool = trim((string)($input['tool_slug'] ?? '')); + $scope = trim((string)($input['scope'] ?? '')); + $engine = trim((string)($input['engine'] ?? '')); + + if ($tool === '' || !preg_match('/^[a-z0-9*\-_.]+$/', $tool)) { + dbnToolsError('Invalid tool slug.', 400, 'bad_tool'); + } + if (!enginesValidScope($scope)) { + dbnToolsError('Invalid scope.', 400, 'bad_scope'); + } + if (!enginesValidEngineForScope($scope, $engine)) { + dbnToolsError('Engine is not valid for this scope.', 400, 'bad_engine'); + } + + $by = strtolower((string)($_SESSION['dbn_tools_user_email'] ?? $_SESSION['dbn_tools_sso_email'] ?? 'owner')); + $stmt = dbnmDb()->prepare( + 'INSERT INTO dbn_tool_engine_config (tool_slug, scope, engine, enabled, updated_by) + VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE engine = VALUES(engine), enabled = 1, updated_by = VALUES(updated_by)' + ); + $stmt->execute([$tool, $scope, $engine, $by]); + + dbnToolsRespond(['ok' => true, 'tool_slug' => $tool, 'scope' => $scope, 'engine' => $engine]); +} + +function enginesClear(): void +{ + $input = dbnToolsJsonInput(2_000); + $tool = trim((string)($input['tool_slug'] ?? '')); + $scope = trim((string)($input['scope'] ?? '')); + + if ($tool === '' || !enginesValidScope($scope)) { + dbnToolsError('Invalid tool or scope.', 400, 'bad_request'); + } + + $stmt = dbnmDb()->prepare('DELETE FROM dbn_tool_engine_config WHERE tool_slug = ? AND scope = ?'); + $stmt->execute([$tool, $scope]); + + dbnToolsRespond(['ok' => true, 'cleared' => true, 'tool_slug' => $tool, 'scope' => $scope]); +} diff --git a/assets/js/dashboard/llm-engines.js b/assets/js/dashboard/llm-engines.js new file mode 100644 index 0000000..8983bd6 --- /dev/null +++ b/assets/js/dashboard/llm-engines.js @@ -0,0 +1,143 @@ +(function () { + 'use strict'; + + const d = window.DBN_DASHBOARD || {}; + const apiBase = (d.apiBase || '/api/dashboard') + '/engines.php'; + + const $context = document.getElementById('engContext'); + const $tier = document.getElementById('engTierPanel'); + const $persona = document.getElementById('engPersonaPanel'); + const $refresh = document.getElementById('engRefresh'); + + const safe = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); + + let tierEngines = []; + let reviewerModels = []; + + function optionsHtml(list, selected) { + return list.map(o => + '' + ).join(''); + } + + function toolLabel(row) { + if (row.tool_slug === '*') { + return row.scope.indexOf('persona') === 0 ? 'All personas' : 'All tier tools'; + } + return row.tool_slug; + } + + function rowHtml(row, list) { + const list2 = list; + const sel = row.override || row.code_default; + const badge = row.override + ? 'override' + : 'default'; + const meta = row.override && row.updated_by + ? '
by ' + safe(row.updated_by) + (row.updated_at ? ' · ' + safe(row.updated_at) : '') + '
' + : ''; + return '
' + + '
' + safe(toolLabel(row)) + '' + + '
default: ' + safe(row.code_default || '—') + '
' + meta + '
' + + '
' + safe(row.scope) + '
' + + '
' + + '
' + badge + + (row.override ? '' : '') + + '
' + + '
'; + } + + function headHtml() { + return '
Tool
Scope
Engine
'; + } + + function render(data) { + tierEngines = data.tier_engines || []; + reviewerModels = data.reviewer_models || []; + + const ctx = data.context || {}; + $context.innerHTML = 'Bedrock: ' + (ctx.bedrock_enabled ? 'ON' : 'OFF') + ' — ' + safe(ctx.note || ''); + + const rows = data.rows || []; + const tierRows = rows.filter(r => r.scope.indexOf('persona') !== 0); + const personaRows = rows.filter(r => r.scope.indexOf('persona') === 0); + + $tier.innerHTML = headHtml() + tierRows.map(r => rowHtml(r, tierEngines)).join(''); + $persona.innerHTML = headHtml() + personaRows.map(r => rowHtml(r, reviewerModels)).join(''); + + bind($tier); + bind($persona); + } + + function bind(panel) { + panel.querySelectorAll('.eng-row').forEach(rowEl => { + const tool = rowEl.getAttribute('data-tool'); + const scope = rowEl.getAttribute('data-scope'); + const select = rowEl.querySelector('.eng-select'); + const clearBtn = rowEl.querySelector('.eng-clear'); + + if (select) { + select.addEventListener('change', () => save(tool, scope, select.value, rowEl)); + } + if (clearBtn) { + clearBtn.addEventListener('click', () => clear(tool, scope, rowEl)); + } + }); + } + + function setBusy(rowEl, busy) { + rowEl.querySelectorAll('select, button').forEach(el => { el.disabled = busy; }); + } + + async function post(action, body) { + const r = await fetch(apiBase + '?action=' + action, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await r.json(); + if (!data.ok) throw new Error(data.message || 'Request failed'); + return data; + } + + async function save(tool, scope, engine, rowEl) { + setBusy(rowEl, true); + try { + await post('set', { tool_slug: tool, scope: scope, engine: engine }); + await load(); + } catch (e) { + alert('Could not save: ' + e.message); + setBusy(rowEl, false); + } + } + + async function clear(tool, scope, rowEl) { + setBusy(rowEl, true); + try { + await post('clear', { tool_slug: tool, scope: scope }); + await load(); + } catch (e) { + alert('Could not clear: ' + e.message); + setBusy(rowEl, false); + } + } + + async function load() { + $tier.innerHTML = '
'; + $persona.innerHTML = '
'; + try { + const r = await fetch(apiBase, { credentials: 'same-origin' }); + const data = await r.json(); + if (!data.ok) throw new Error(data.message || 'Could not load engine map'); + render(data); + } catch (e) { + $context.textContent = 'Could not load: ' + e.message; + $tier.innerHTML = ''; + $persona.innerHTML = ''; + } + } + + if ($refresh) $refresh.addEventListener('click', load); + load(); +})(); diff --git a/dashboard/llm-engines.php b/dashboard/llm-engines.php new file mode 100644 index 0000000..191307a --- /dev/null +++ b/dashboard/llm-engines.php @@ -0,0 +1,66 @@ +Owner access required' + . '

' + . 'Owner access required. Back to dashboard

'; + 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;