['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, }; } }