Files
dobetternorge-tools/includes/ToolModels.php
T
daveadmin 2f05b84b0f 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>
2026-06-21 14:55:17 +02:00

309 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/FreeTier.php';
/**
* Tier-aware model routing for tools that expose the existing engine selector.
*
* Plus/trial users get the cost-controlled Azure mini path. Pro users get the
* full Azure path. CaveauAI sessions keep the engine requested by their UI.
*/
final class ToolModels
{
public const TIMELINE_QUICK_CHAR_LIMIT = 25000;
public const TIMELINE_STANDARD_CHAR_LIMIT = 55000;
public const TIMELINE_DEEP_CHAR_LIMIT = 128000;
public const TIMELINE_QUICK_MAX_CHARS = 100000;
public const TIMELINE_STANDARD_MAX_CHARS = 300000;
public const TIMELINE_DEEP_MAX_CHARS = 600000;
/**
* Canonical redact engine registry. Server-side source of truth for which engines
* exist, their credit cost, and whether the client UI may offer them. Credits are
* ALWAYS resolved from here — never from a client-supplied value.
*
* gpu_legal is the fine-tuned legal model (Phase 6 roadmap) — wired but not yet
* client-selectable until the harness proves a lift.
*/
public const REDACT_ENGINES = [
'azure_mini' => ['label' => 'Azure GPT-4o-mini', 'credits' => 1, 'client_selectable' => true, 'speed' => 'fast'],
'azure_full' => ['label' => 'Azure GPT-4o', 'credits' => 2, 'client_selectable' => true, 'speed' => 'medium'],
'claude_haiku' => ['label' => 'Claude Haiku 4.5', 'credits' => 1, 'client_selectable' => true, 'speed' => 'fast'],
'claude_sonnet' => ['label' => 'Claude Sonnet 4.6', 'credits' => 2, 'client_selectable' => true, 'speed' => 'medium'],
'gpu' => ['label' => 'GPU Qwen 2.5 14B', 'credits' => 1, 'client_selectable' => true, 'speed' => 'slow'],
'gpu_legal' => ['label' => 'GPU Legal (Qwen FT)','credits' => 1, 'client_selectable' => false, 'speed' => 'slow'],
'regex' => ['label' => 'Regex only', 'credits' => 0, 'client_selectable' => false, 'speed' => 'instant'],
];
public static function isValidRedactEngine(string $engine): bool
{
return isset(self::REDACT_ENGINES[$engine]);
}
/** Normalise an incoming engine to a valid key, defaulting to claude_haiku. */
public static function redactEngine(string $engine): string
{
return self::isValidRedactEngine($engine) ? $engine : 'claude_haiku';
}
/** Server-side credit cost for a redact engine (never trust the client). */
public static function redactCredits(string $engine): int
{
return self::REDACT_ENGINES[self::redactEngine($engine)]['credits'];
}
/** Engine keys the client UI is allowed to offer. */
public static function redactSelectableEngines(): array
{
return array_keys(array_filter(
self::REDACT_ENGINES,
static fn(array $e) => $e['client_selectable']
));
}
/**
* Unified quality-tier registry for the Quick/Pro selector exposed on the analytical
* tools (summarize, ask, legal-analysis, barnevernet, korrespond, discrepancy,
* deep-research). Each tier maps to an existing engine string that the service layer
* already resolves to a gateway (resolveChatGateway/personaGateway) — so no service
* change is needed. Credits = the tool's base cost × tier multiplier, resolved
* server-side ONLY.
*
* Anchors picked from the 2026-06-15 tier benchmark (credits-first, Bedrock):
* quick → claude_haiku (96.7% quality, ~3.8s); pro → claude_sonnet (100%, ~13s).
*/
public const QUALITY_TIERS = [
'quick' => ['label' => 'Quick', 'engine' => 'claude_haiku', 'credit_mult' => 1],
'pro' => ['label' => 'Pro', 'engine' => 'claude_sonnet', 'credit_mult' => 2],
];
/** Normalise an incoming tier to a valid key, defaulting to quick. */
public static function qualityTier(string $tier): string
{
return isset(self::QUALITY_TIERS[$tier]) ? $tier : 'quick';
}
/** The existing engine string backing a quality tier. */
public static function tierEngine(string $tier): string
{
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
{
return self::QUALITY_TIERS[self::qualityTier($tier)]['label'];
}
/** Server-side credit cost for a tool at a given tier (base cost × tier multiplier). */
public static function tierCredits(string $tool, string $tier): int
{
$mult = self::QUALITY_TIERS[self::qualityTier($tier)]['credit_mult'];
return max(1, PricingCatalog::toolCost($tool) * $mult);
}
/**
* Resolve a requested quality tier for a user + tool. Applies the subscription gate
* (free / anonymous → quick only; plus & pro may pick pro) and returns the backing
* engine plus the server-side credit cost. Credits are NEVER trusted from the client.
*
* @return array{tier:string,engine:string,credits:int,label:string}
*/
public static function resolveTier(int $userId, string $tool, string $requestedTier): array
{
$tier = self::qualityTier($requestedTier);
if ($tier === 'pro' && $userId > 0 && FreeTier::tier($userId) === 'free') {
$tier = 'quick';
}
return [
'tier' => $tier,
'engine' => self::tierEngine($tier),
'credits' => self::tierCredits($tool, $tier),
'label' => self::tierLabel($tier),
];
}
public static function engineForUser(int $userId, string $requestedEngine): string
{
$valid = ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex', 'claude_haiku', 'claude_sonnet'];
$requestedEngine = in_array($requestedEngine, $valid, true) ? $requestedEngine : 'azure_mini';
if ($userId <= 0) {
return $requestedEngine;
}
return match (FreeTier::tier($userId)) {
'pro' => $requestedEngine,
'plus' => $requestedEngine === 'azure_full' ? 'azure_mini' : $requestedEngine,
default => in_array($requestedEngine, ['nova_lite', 'regex'], true) ? $requestedEngine : 'nova_lite',
};
}
public static function timelineRoute(int $userId, string $requestedEngine, string $text): array
{
$valid = ['nova_lite', 'azure_mini', 'azure_full', 'claude_haiku', 'claude_sonnet'];
$requestedEngine = in_array($requestedEngine, $valid, true) ? $requestedEngine : 'azure_mini';
$tierEngine = self::engineForUser($userId, $requestedEngine);
$charCount = mb_strlen($text, 'UTF-8');
if ($charCount > self::TIMELINE_DEEP_MAX_CHARS) {
throw new DbnToolsHttpException(
'This timeline input is too large after selected documents or My Case context were added. Split the file or use fewer selected documents.',
413,
'timeline_input_too_large',
['input_char_count' => $charCount, 'max_chars' => self::TIMELINE_DEEP_MAX_CHARS]
);
}
$effectiveEngine = $tierEngine;
if ($charCount > self::timelineEngineMaxChars($effectiveEngine)) {
$effectiveEngine = $charCount <= self::TIMELINE_STANDARD_MAX_CHARS ? 'azure_mini' : 'azure_full';
} elseif ($charCount > self::TIMELINE_STANDARD_CHAR_LIMIT && $effectiveEngine === 'nova_lite') {
$effectiveEngine = $charCount <= self::TIMELINE_QUICK_MAX_CHARS ? 'nova_lite' : 'azure_mini';
}
if ($charCount > self::timelineEngineMaxChars($effectiveEngine)) {
$effectiveEngine = 'azure_full';
}
$credits = self::timelineCreditsForSize($effectiveEngine, $charCount);
$baseCredits = self::timelineAdvertisedCredits($requestedEngine);
$requiresConfirmation = $credits > $baseCredits
|| self::timelineEngineRank($effectiveEngine) > self::timelineEngineRank($requestedEngine);
$chunked = $charCount > self::timelineEngineLimit($effectiveEngine);
return [
'requested_engine' => $requestedEngine,
'tier_engine' => $tierEngine,
'effective_engine' => $effectiveEngine,
'auto_upgraded_engine' => $effectiveEngine !== $tierEngine,
'input_char_count' => $charCount,
'engine_limit_chars' => self::timelineEngineLimit($effectiveEngine),
'max_char_limit' => self::timelineEngineMaxChars($effectiveEngine),
'chunked_timeline' => $chunked,
'timeline_chunk_count' => $chunked ? (int)ceil($charCount / self::timelineChunkSize($effectiveEngine)) : 1,
'estimated_credits' => $credits,
'credits' => $credits,
'base_credits' => $baseCredits,
'requires_confirmation' => $requiresConfirmation,
];
}
public static function assertTimelineQuoteAccepted(array $route, array $input): void
{
if (empty($route['requires_confirmation'])) {
return;
}
$accepted = !empty($input['accepted_timeline_quote'])
&& (int)($input['accepted_credits'] ?? 0) === (int)$route['credits']
&& (string)($input['accepted_effective_engine'] ?? '') === (string)$route['effective_engine'];
if ($accepted) {
return;
}
$engineLabel = self::timelineEngineLabel((string)$route['effective_engine']);
throw new DbnToolsHttpException(
'This timeline is larger than the selected engine can handle at the advertised price. Confirm the quoted engine and credits before running.',
409,
'timeline_quote_required',
['timeline_quote' => array_merge($route, [
'effective_engine_label' => $engineLabel,
'message' => 'Timeline will use ' . $engineLabel . ' for '
. number_format((int)$route['input_char_count'])
. ' characters across about ' . (int)$route['timeline_chunk_count']
. ' chunk(s), costing ' . (int)$route['credits'] . ' credit(s).',
])]
);
}
public static function timelineCredits(string $engine): int
{
return self::timelineAdvertisedCredits($engine);
}
public static function timelineEngineLimit(string $engine): int
{
return match ($engine) {
'nova_lite', 'claude_haiku' => self::TIMELINE_QUICK_CHAR_LIMIT,
'azure_mini' => self::TIMELINE_STANDARD_CHAR_LIMIT,
default => self::TIMELINE_DEEP_CHAR_LIMIT,
};
}
public static function timelineChunkSize(string $engine): int
{
return match ($engine) {
'nova_lite', 'claude_haiku' => 10000,
'azure_mini' => 16000,
default => 30000,
};
}
public static function timelineEngineMaxChars(string $engine): int
{
return match ($engine) {
'nova_lite', 'claude_haiku' => self::TIMELINE_QUICK_MAX_CHARS,
'azure_mini' => self::TIMELINE_STANDARD_MAX_CHARS,
default => self::TIMELINE_DEEP_MAX_CHARS,
};
}
public static function timelineCreditsForSize(string $engine, int $charCount): int
{
return match ($engine) {
'nova_lite', 'claude_haiku'
=> $charCount <= self::TIMELINE_QUICK_CHAR_LIMIT ? 1 : 2,
'azure_mini'
=> $charCount <= self::TIMELINE_STANDARD_CHAR_LIMIT ? 1 : ($charCount <= 180000 ? 2 : 3),
default
=> $charCount <= self::TIMELINE_DEEP_CHAR_LIMIT ? 2 : ($charCount <= 350000 ? 4 : 6),
};
}
public static function timelineAdvertisedCredits(string $engine): int
{
return in_array($engine, ['azure_full', 'claude_sonnet'], true) ? 2 : 1;
}
public static function timelineEngineLabel(string $engine): string
{
return match ($engine) {
'nova_lite', 'claude_haiku' => 'Quick',
'azure_full', 'claude_sonnet' => 'Deep',
default => 'Standard',
};
}
private static function timelineEngineRank(string $engine): int
{
return match ($engine) {
'nova_lite', 'claude_haiku' => 1,
'azure_mini' => 2,
'azure_full', 'claude_sonnet' => 3,
default => 0,
};
}
}