8a11001bff
Routes AI tools across three tiers based on task complexity: - Azure GPT-4o-mini always: redact, translate, timeline-basic, search-legal (mechanical tasks) - Claude Haiku 4.5 (Bedrock): ask, summarize, timeline-deep, citations (Norwegian nuance) - Claude Sonnet 4.6 (Bedrock): korrespond, legal-analysis, deep-research, barnevernet-analyze, discrepancy-find, advocate (public-facing legal output) No AWS credentials in app — credentials live in LiteLLM on Colin (same as nova-lite). Rollback: DBN_BEDROCK_ENABLED=false in .env, no code push needed. Includes extended thinking support for Pro deep-research via chatWithThinking(). Claude Opus 4.7 constant added for future premium tier (needs litellm_config.yaml entry). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
7.6 KiB
PHP
185 lines
7.6 KiB
PHP
<?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;
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|