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]);
|
||||
}
|
||||
Reference in New Issue
Block a user