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'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
|
||||
+78
-2
@@ -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,
|
||||
|
||||
@@ -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)'];
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<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