Files
dobetternorge-tools/includes/ToolModels.php
T
daveadmin a8b1bb87a6 feat(tools): converge two-tier Quick/Pro selector onto .no fork
Port the dobetterlegal-tools two-tier quality stack to dobetternorge.no:
QUALITY_TIERS registry + resolveTier (ToolModels), dbnToolsResolveToolRun
(bootstrap), tier read+charge in the 6 analytical endpoints, Quick/Pro
UI + payload.tier on the 6 tool pages/JS, and the bounded
corpusContextForSummarize RAG fix (per-passage trim + total budget +
reranker_enabled). Back-compat: requests without `tier` keep legacy
engine behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 12:23:46 +02:00

293 lines
12 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'];
}
/** 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,
};
}
}