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:
2026-06-21 14:55:17 +02:00
parent f270a32056
commit 2f05b84b0f
7 changed files with 546 additions and 2 deletions
+213
View File
@@ -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]);
}