feat(tools): DB-backed LLM engine admin (owner-only)
Add an owner-gated dashboard to remap any tool/tier's model live without a code push. Overrides live in dbn_tool_engine_config (dobetternorge_maindb) and are consulted by dbnToolsResolveToolRun() + dbnToolsReviewerModel(); no row = unchanged code/.env behaviour (fully back-compatible). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /api/dashboard/engines.php — owner-only LLM engine override admin.
|
||||||
|
*
|
||||||
|
* GET → { ok, context, tier_engines, reviewer_models, tier_tools, rows }
|
||||||
|
* POST ?action=set body: { tool_slug, scope, engine } → upsert an override
|
||||||
|
* POST ?action=clear body: { tool_slug, scope } → delete (revert to default)
|
||||||
|
*
|
||||||
|
* Overrides live in dobetternorge_maindb.dbn_tool_engine_config and are consulted by
|
||||||
|
* dbnToolsResolveToolRun() / dbnToolsReviewerModel(). Owner-gated via dbnToolsIsOwner().
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/ToolModels.php';
|
||||||
|
|
||||||
|
dbnToolsRequireAuth();
|
||||||
|
|
||||||
|
if (!dbnToolsIsOwner()) {
|
||||||
|
dbnToolsError('Owner access required.', 403, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGINES_TIER_TOOLS = ['ask', 'summarize', 'deep-research', 'korrespond', 'barnevernet', 'discrepancy'];
|
||||||
|
const ENGINES_TIER_SCOPES = ['tier_quick', 'tier_pro', 'legacy'];
|
||||||
|
const ENGINES_PERSONA_SCOPES = ['persona_family', 'persona_general'];
|
||||||
|
|
||||||
|
function enginesTierLabel(string $key): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'claude_haiku' => '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]);
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
|
'<option value="' + safe(o.key) + '"' + (o.key === selected ? ' selected' : '') + '>' + safe(o.label) + '</option>'
|
||||||
|
).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
|
||||||
|
? '<span class="eng-badge">override</span>'
|
||||||
|
: '<span class="eng-badge eng-badge--default">default</span>';
|
||||||
|
const meta = row.override && row.updated_by
|
||||||
|
? '<div class="eng-meta">by ' + safe(row.updated_by) + (row.updated_at ? ' · ' + safe(row.updated_at) : '') + '</div>'
|
||||||
|
: '';
|
||||||
|
return '<div class="eng-row" data-tool="' + safe(row.tool_slug) + '" data-scope="' + safe(row.scope) + '">' +
|
||||||
|
'<div><span class="eng-row__tool">' + safe(toolLabel(row)) + '</span>' +
|
||||||
|
'<div class="eng-row__def">default: ' + safe(row.code_default || '—') + '</div>' + meta + '</div>' +
|
||||||
|
'<div class="eng-row__scope">' + safe(row.scope) + '</div>' +
|
||||||
|
'<div><select class="eng-select">' + optionsHtml(list2, sel) + '</select></div>' +
|
||||||
|
'<div class="eng-row__actions">' + badge +
|
||||||
|
(row.override ? '<button class="dash-btn eng-clear" type="button">Clear</button>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function headHtml() {
|
||||||
|
return '<div class="eng-head"><div>Tool</div><div>Scope</div><div>Engine</div><div></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
tierEngines = data.tier_engines || [];
|
||||||
|
reviewerModels = data.reviewer_models || [];
|
||||||
|
|
||||||
|
const ctx = data.context || {};
|
||||||
|
$context.innerHTML = '<strong>Bedrock: ' + (ctx.bedrock_enabled ? 'ON' : 'OFF') + '</strong> — ' + 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 = '<div class="dms-loading"></div>';
|
||||||
|
$persona.innerHTML = '<div class="dms-loading"></div>';
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
|
|
||||||
|
if (!dbnToolsIsAuthenticated()) {
|
||||||
|
dbnToolsRequirePageAuth($_SERVER['REQUEST_URI'] ?? '/dashboard/llm-engines.php');
|
||||||
|
}
|
||||||
|
if (!dbnToolsIsOwner()) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo '<!doctype html><meta charset="utf-8"><title>Owner access required</title>'
|
||||||
|
. '<p style="font-family:sans-serif;max-width:540px;margin:4rem auto;">'
|
||||||
|
. 'Owner access required. <a href="/dashboard/">Back to dashboard</a></p>';
|
||||||
|
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';
|
||||||
|
?>
|
||||||
|
<section class="dash-card">
|
||||||
|
<div class="dash-card__head">
|
||||||
|
<h2>Routing context</h2>
|
||||||
|
<div class="dash-card__actions">
|
||||||
|
<button class="dash-btn" type="button" id="engRefresh">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="engContext" style="margin:0; max-width:74ch; line-height:1.6; font-size:0.9rem; color:rgba(22,19,15,0.7);">Loading…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dash-card">
|
||||||
|
<div class="dash-card__head">
|
||||||
|
<h2>Tier engines (Quick / Pro)</h2>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:0; max-width:74ch; line-height:1.6; font-size:0.86rem; color:rgba(22,19,15,0.6);">
|
||||||
|
The <code>*</code> 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.
|
||||||
|
</p>
|
||||||
|
<div id="engTierPanel" class="eng-table"><div class="dms-loading"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dash-card">
|
||||||
|
<div class="dash-card__head">
|
||||||
|
<h2>Reviewer personas (legal-analysis track)</h2>
|
||||||
|
</div>
|
||||||
|
<div id="engPersonaPanel" class="eng-table"><div class="dms-loading"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.eng-table { background:#fff; border:1px solid var(--dms-stroke,#e3ddd2); border-radius:var(--dms-radius,10px); overflow:hidden; }
|
||||||
|
.eng-row { display:grid; grid-template-columns:1.4fr 0.9fr 1.5fr auto; gap:12px; padding:10px 16px; border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); font-size:13px; align-items:center; }
|
||||||
|
.eng-row:last-child { border-bottom:0; }
|
||||||
|
.eng-row__tool { font-weight:600; color:var(--dms-navy,#16130f); }
|
||||||
|
.eng-row__scope { font-family:ui-monospace,Menlo,monospace; font-size:11px; color:rgba(22,19,15,0.55); }
|
||||||
|
.eng-row__def { font-size:11px; color:rgba(22,19,15,0.55); margin-top:2px; }
|
||||||
|
.eng-row select { width:100%; padding:5px 8px; border:1px solid var(--dms-stroke,#d9d2c5); border-radius:6px; font-size:12px; background:#fff; }
|
||||||
|
.eng-row__actions { display:flex; gap:6px; align-items:center; }
|
||||||
|
.eng-badge { font-size:10px; font-weight:600; padding:2px 7px; border-radius:999px; background:rgba(180,138,44,0.16); color:#6c5212; }
|
||||||
|
.eng-badge--default { background:rgba(22,19,15,0.06); color:rgba(22,19,15,0.5); }
|
||||||
|
.eng-meta { font-size:10px; color:rgba(22,19,15,0.45); }
|
||||||
|
.eng-clear { font-size:11px; padding:4px 8px; }
|
||||||
|
.eng-head { display:grid; grid-template-columns:1.4fr 0.9fr 1.5fr auto; gap:12px; padding:8px 16px; font-size:10px; text-transform:uppercase; letter-spacing:0.05em; color:rgba(22,19,15,0.4); background:rgba(22,19,15,0.02); border-bottom:1px solid var(--dms-stroke-soft,#efe9dd); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="/assets/js/dashboard/llm-engines.js" defer></script>
|
||||||
|
|
||||||
|
<?php require_once __DIR__ . '/../includes/layout_dashboard_footer.php'; ?>
|
||||||
@@ -91,6 +91,22 @@ final class ToolModels
|
|||||||
return self::QUALITY_TIERS[self::qualityTier($tier)]['engine'];
|
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). */
|
/** Human label for a quality tier (UI / telemetry). */
|
||||||
public static function tierLabel(string $tier): string
|
public static function tierLabel(string $tier): string
|
||||||
{
|
{
|
||||||
|
|||||||
+77
-1
@@ -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
|
function dbnToolsRequiredPackageSlug(): string
|
||||||
{
|
{
|
||||||
return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
|
return dbnToolsEnv('DBN_CAVEAU_PACKAGE_SLUG') ?: 'family-legal';
|
||||||
@@ -506,9 +564,17 @@ function dbnToolsPersonaTrack(?string $slug): string
|
|||||||
function dbnToolsReviewerModel(?string $slug = null): string
|
function dbnToolsReviewerModel(?string $slug = null): string
|
||||||
{
|
{
|
||||||
if (dbnToolsPersonaTrack($slug) === 'family') {
|
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'))
|
return trim((string)(dbnToolsEnv('DBN_REVIEW_MODEL_FAMILY', 'dbn-legal-agent-v3') ?? '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';
|
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'])) {
|
if (isset($input['tier'])) {
|
||||||
$res = ToolModels::resolveTier(dbnToolsFreeTierUid(), $tool, (string)$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']);
|
$ftUid = dbnToolsFreeTierCheckAmount($tool, $res['credits']);
|
||||||
return [
|
return [
|
||||||
'tier' => $res['tier'],
|
'tier' => $res['tier'],
|
||||||
@@ -801,7 +872,12 @@ function dbnToolsResolveToolRun(string $tool, array $input, string $legacyDefaul
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ftUid = dbnToolsFreeTierCheck($tool);
|
$ftUid = dbnToolsFreeTierCheck($tool);
|
||||||
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? $legacyDefaultEngine));
|
$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 [
|
return [
|
||||||
'tier' => '',
|
'tier' => '',
|
||||||
'engine' => $engine,
|
'engine' => $engine,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ $dashboardNav = [
|
|||||||
'trash' => ['url' => '/dashboard/trash.php', 'label' => dbnToolsT('dash_nav_trash', $uiLang) ?: 'Trash', 'sub' => 'Restore or purge'],
|
'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'],
|
'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)'];
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user