diff --git a/.gitignore b/.gitignore index 91d40b3..20914d4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.log *.jsonl /support/ +/test-gateway.php diff --git a/includes/BvjAnalyzerAgent.php b/includes/BvjAnalyzerAgent.php index c7fbd82..04aba32 100644 --- a/includes/BvjAnalyzerAgent.php +++ b/includes/BvjAnalyzerAgent.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/AzureOpenAiGateway.php'; +require_once __DIR__ . '/DbnGatewayFactory.php'; /** * BVJ (Barnevernet) Analyzer Agent @@ -29,13 +30,13 @@ final class DbnBvjAnalyzerAgent // Steps 1-3 always use this engine — fast and cheap for structured extraction private const EXTRACT_ENGINE = 'azure_mini'; - private DbnAzureOpenAiGateway $azure; + private DbnAzureOpenAiGateway|DbnBedrockGateway $azure; private array $uploadVecs = []; private array $stepTimings = []; - public function __construct(?DbnAzureOpenAiGateway $azure = null) + public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null) { - $this->azure = $azure ?: new DbnAzureOpenAiGateway(); + $this->azure = $azure ?: DbnGatewayFactory::makeForTool('barnevernet-analyze'); } /** diff --git a/includes/DbnBedrockGateway.php b/includes/DbnBedrockGateway.php new file mode 100644 index 0000000..c52d876 --- /dev/null +++ b/includes/DbnBedrockGateway.php @@ -0,0 +1,283 @@ +liteLlmUrl = $base . '/v1/chat/completions'; + $this->liteLlmKey = (string)(dbnToolsEnv('LITELLM_MASTER_KEY') ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d'); + $this->chatModelName = $config['chat_model_name'] ?? (string)dbnToolsEnv('DBN_BEDROCK_CHAT_MODEL', 'claude-sonnet-bedrock'); + $this->embeddingModelName = $config['embedding_model_name'] ?? (string)dbnToolsEnv('DBN_BEDROCK_EMBEDDING_MODEL', 'amazon.titan-embed-text-v2:0'); + } + + // ── Interface parity with DbnAzureOpenAiGateway ─────────────────────────── + + public function missingChatConfig(): array + { + $missing = []; + if (trim($this->liteLlmUrl) === '') $missing[] = 'litellm_url'; + if (trim($this->chatModelName) === '') $missing[] = 'chat_model_name'; + return $missing; + } + + public function missingEmbeddingConfig(): array + { + return trim($this->embeddingModelName) === '' ? ['embedding_model_name'] : []; + } + + public function requireChat(): void + { + $missing = $this->missingChatConfig(); + if ($missing) { + dbnToolsAbort( + 'Bedrock gateway (LiteLLM) is missing configuration: ' . implode(', ', $missing) . '.', + 503, + 'bedrock_config_missing', + ['missing' => $missing] + ); + } + } + + public function requireEmbedding(): void + { + $missing = $this->missingEmbeddingConfig(); + if ($missing) { + dbnToolsAbort( + 'Bedrock embedding gateway (LiteLLM) missing: ' . implode(', ', $missing) . '.', + 503, + 'bedrock_embedding_config_missing', + ['missing' => $missing] + ); + } + } + + public function withDeployment(string $modelName): static + { + $clone = clone $this; + $clone->chatModelName = $modelName; + return $clone; + } + + public function chatDeployment(): string + { + return $this->chatModelName; + } + + public function embeddingDeployment(): string + { + return $this->embeddingModelName; + } + + public function chat(array $messages, array $options = []): array + { + $this->requireChat(); + + $payload = [ + 'model' => $this->chatModelName, + 'messages' => $messages, + 'temperature' => (float)($options['temperature'] ?? 0.2), + 'max_tokens' => $options['max_tokens'] ?? 1200, + ]; + if (!empty($options['json'])) { + $payload['response_format'] = ['type' => 'json_object']; + } + + return $this->postJson($this->liteLlmUrl, $payload, (int)($options['timeout'] ?? 90)); + } + + public function chatText(array $messages, array $options = []): string + { + $response = $this->chat($messages, $options); + $content = $response['choices'][0]['message']['content'] ?? ''; + if (!is_string($content) || trim($content) === '') { + throw new RuntimeException('Bedrock (LiteLLM) returned an empty chat response.'); + } + return trim($content); + } + + public function embeddings(array|string $input, array $options = []): array + { + $this->requireEmbedding(); + $url = rtrim((string)dbnToolsEnv('LITELLM_BASE_URL', 'http://10.0.1.10:4000'), '/') . '/v1/embeddings'; + return $this->postJson($url, [ + 'model' => $this->embeddingModelName, + 'input' => $input, + ], (int)($options['timeout'] ?? 30)); + } + + public function ping(int $timeout = 8): bool + { + try { + $text = $this->chatText([ + ['role' => 'system', 'content' => 'Return one word only: ok'], + ['role' => 'user', 'content' => 'health'], + ], ['temperature' => 0, 'max_tokens' => 5, 'timeout' => $timeout]); + return trim($text) !== ''; + } catch (Throwable $e) { + error_log('DBN Bedrock (LiteLLM) health check failed: ' . $e->getMessage()); + return false; + } + } + + public function decodeJsonObject(string $content): ?array + { + $content = trim($content); + $content = (string)preg_replace('/^```(?:json)?\s*\n?/i', '', $content); + $content = (string)preg_replace('/\n?```\s*$/', '', $content); + $content = trim($content); + + $decoded = json_decode($content, true); + if (is_array($decoded)) { + return $decoded; + } + + $start = strpos($content, '{'); + $end = strrpos($content, '}'); + if ($start !== false && $end !== false && $end > $start) { + $candidate = substr($content, $start, $end - $start + 1); + $decoded = json_decode($candidate, true); + if (is_array($decoded)) { + return $decoded; + } + } + return null; + } + + // ── Bedrock-specific ────────────────────────────────────────────────────── + + /** + * Extended thinking via LiteLLM — passes thinking params through to Bedrock. + * LiteLLM forwards additionalModelRequestFields to the Bedrock Converse API. + * Returns ['text' => string, 'thinking' => string|null, 'usage' => array]. + */ + public function chatWithThinking(array $messages, array $options = []): array + { + $this->requireChat(); + + $budget = (int)($options['thinking_budget'] ?? 8000); + $maxTokens = (int)($options['max_tokens'] ?? max($budget + 4000, 16000)); + if ($maxTokens <= $budget) { + $maxTokens = $budget + 4000; + } + + $payload = [ + 'model' => $this->chatModelName, + 'messages' => $messages, + 'temperature' => 1.0, // required for extended thinking + 'max_tokens' => $maxTokens, + 'thinking' => [ // LiteLLM passes this to Bedrock as additionalModelRequestFields + 'type' => 'enabled', + 'budget_tokens'=> $budget, + ], + ]; + + $response = $this->postJson($this->liteLlmUrl, $payload, (int)($options['timeout'] ?? 300)); + + // LiteLLM may surface thinking in 'thinking' field or as a special content block + $content = $response['choices'][0]['message']['content'] ?? ''; + $thinking = $response['choices'][0]['message']['thinking'] ?? null; + + // If content is an array of blocks (pass-through of Bedrock format), extract text+thinking + if (is_array($content)) { + $text = ''; + $thinking = null; + foreach ($content as $block) { + if (($block['type'] ?? '') === 'thinking') { + $thinking = $block['thinking'] ?? null; + } elseif (($block['type'] ?? '') === 'text') { + $text .= $block['text'] ?? ''; + } + } + $content = trim($text); + } + + return [ + 'text' => trim((string)$content), + 'thinking'=> $thinking, + 'usage' => $response['usage'] ?? [], + ]; + } + + // ── Private: HTTP ───────────────────────────────────────────────────────── + + private function postJson(string $url, array $payload, int $timeout): array + { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($body === false) { + throw new RuntimeException('Unable to encode Bedrock (LiteLLM) request.'); + } + + $headers = [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $this->liteLlmKey, + ]; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => $timeout, + ]); + $response = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false) { + throw new RuntimeException('Bedrock (LiteLLM) cURL failed: ' . $error); + } + return $this->decodeResponse($response, $code); + } + + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'timeout' => $timeout, + 'ignore_errors' => true, + ]]); + $response = @file_get_contents($url, false, $context); + $code = 0; + if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) { + $code = (int)$m[1]; + } + if ($response === false) { + throw new RuntimeException('Bedrock (LiteLLM) request failed.'); + } + return $this->decodeResponse($response, $code); + } + + private function decodeResponse(string $response, int $code): array + { + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + throw new RuntimeException('Bedrock (LiteLLM) returned non-JSON (HTTP ' . $code . ').'); + } + if ($code < 200 || $code >= 300) { + $message = $decoded['error']['message'] ?? $decoded['message'] ?? ('HTTP ' . $code); + throw new RuntimeException('Bedrock (LiteLLM) request failed: ' . $message); + } + return $decoded; + } +} diff --git a/includes/DbnBedrockModelRouter.php b/includes/DbnBedrockModelRouter.php new file mode 100644 index 0000000..24d7558 --- /dev/null +++ b/includes/DbnBedrockModelRouter.php @@ -0,0 +1,89 @@ + 'azure'|'bedrock', 'model' => string|null]. + * gateway='azure' means always use Azure regardless of DBN_BEDROCK_ENABLED. + * gateway='bedrock' means use Bedrock when enabled, fall back to Azure when not. + * model is null for azure-pinned tools (caller uses DbnAzureOpenAiGateway directly). + */ + public static function routeForTool(string $tool, string $tier = 'free'): array + { + $tier = in_array($tier, ['free', 'plus', 'pro'], true) ? $tier : 'free'; + + if (in_array($tool, self::AZURE_PINNED, true)) { + return ['gateway' => 'azure', 'model' => null]; + } + + if (in_array($tool, self::HAIKU_TOOLS, true)) { + // Pro users get Sonnet for these tools; free/plus get Haiku + $model = ($tier === 'pro') ? self::LITELLM_SONNET : self::LITELLM_HAIKU; + return ['gateway' => 'bedrock', 'model' => $model]; + } + + // All drafting/reasoning tools → Sonnet (korrespond, legal-analysis, deep-research, + // barnevernet-analyze, discrepancy-find, advocate) + return ['gateway' => 'bedrock', 'model' => self::LITELLM_SONNET]; + } + + /** @deprecated Use routeForTool() — kept for any direct callers outside the factory. */ + public static function modelForTool(string $tool, string $tier = 'free'): string + { + $route = self::routeForTool($tool, $tier); + return $route['model'] ?? self::LITELLM_SONNET; + } + + public static function supportsThinking(string $modelName): bool + { + return in_array($modelName, self::THINKING_MODELS, true); + } + + public static function maxTokensForTool(string $tool): int + { + return match ($tool) { + 'deep-research', 'barnevernet-analyze', 'advocate' => 4000, + 'legal-analysis', 'korrespond', 'discrepancy-find' => 3000, + default => 2000, + }; + } +} diff --git a/includes/DbnGatewayFactory.php b/includes/DbnGatewayFactory.php new file mode 100644 index 0000000..b211061 --- /dev/null +++ b/includes/DbnGatewayFactory.php @@ -0,0 +1,65 @@ + $modelName + ?? (string)dbnToolsEnv('DBN_BEDROCK_CHAT_MODEL', DbnBedrockModelRouter::LITELLM_SONNET), + ]); + } + + /** + * Returns a gateway pre-configured with the best model for the given tool and tier. + * + * Three-tier routing: + * - Azure-pinned tools (redact, translate, timeline, search-legal) → always Azure, Bedrock flag ignored. + * - Haiku tools (ask, summarize, timeline-deep, citations) → Haiku when Bedrock on, Azure when off. + * - Sonnet tools (korrespond, legal-analysis, deep-research, etc.) → Sonnet when on, Azure when off. + */ + public static function makeForTool( + string $tool, + string $tier = 'free' + ): DbnAzureOpenAiGateway|DbnBedrockGateway { + $route = DbnBedrockModelRouter::routeForTool($tool, $tier); + + // Azure-pinned tools bypass the Bedrock flag entirely + if ($route['gateway'] === 'azure') { + return new DbnAzureOpenAiGateway(); + } + + // Bedrock tools fall back to Azure when disabled + if (!self::bedrockEnabled()) { + return new DbnAzureOpenAiGateway(); + } + + return self::make($route['model']); + } +} diff --git a/includes/DeepResearchAgent.php b/includes/DeepResearchAgent.php index c3c3877..1638c12 100644 --- a/includes/DeepResearchAgent.php +++ b/includes/DeepResearchAgent.php @@ -3,6 +3,8 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/AzureOpenAiGateway.php'; +require_once __DIR__ . '/DbnGatewayFactory.php'; +require_once __DIR__ . '/DbnBedrockModelRouter.php'; final class DbnDeepResearchAgent { @@ -13,13 +15,13 @@ final class DbnDeepResearchAgent private const MIN_CHUNK_WORDS = 50; private const POOL_CAP = 30; - private DbnAzureOpenAiGateway $azure; + private DbnAzureOpenAiGateway|DbnBedrockGateway $azure; private array $uploadVecs = []; private array $stepTimings = []; - public function __construct(?DbnAzureOpenAiGateway $azure = null) + public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null) { - $this->azure = $azure ?: new DbnAzureOpenAiGateway(); + $this->azure = $azure ?: DbnGatewayFactory::makeForTool('deep-research'); } public function run( @@ -984,8 +986,10 @@ PROMPT; 'dbn_legal' => 'dbn-legal-agent-v2', 'dbn_legal_v3' => 'dbn-legal-agent-v3', 'azure_full' => 'gpt-4o', + 'claude_sonnet'=> 'Claude 3.5 Sonnet', default => $this->azure->chatDeployment(), }, + 'thinking_trace'=> null, ]; } @@ -1118,8 +1122,9 @@ PROMPT; ['role' => 'system', 'content' => 'You return valid JSON only. No markdown fences. Every legal claim must be supported by a source from the numbered list. Do not invent statute sections, case names, paragraph numbers, or dates. If no source supports a point, omit it entirely.'], ['role' => 'user', 'content' => $prompt], ]; - $synthTemp = ($advocateRole !== '') ? min($temperature, 0.20) : $temperature; - $opts = ['json' => true, 'temperature' => $synthTemp, 'max_tokens' => 4000, 'timeout' => 180]; + $synthTemp = ($advocateRole !== '') ? min($temperature, 0.20) : $temperature; + $opts = ['json' => true, 'temperature' => $synthTemp, 'max_tokens' => 4000, 'timeout' => 180]; + $thinkingTrace = null; try { if ($engine === 'dbn_legal_v3') { @@ -1137,6 +1142,28 @@ PROMPT; } elseif ($engine === 'azure_full') { $raw = $this->azure->withDeployment('gpt-4o')->chatText($messages, $opts); $deployLabel = 'gpt-4o'; + } elseif ($engine === 'claude_sonnet' || ($this->azure instanceof DbnBedrockGateway)) { + if ( + $this->azure instanceof DbnBedrockGateway + && dbnToolsEnv('DBN_BEDROCK_THINKING_ENABLED', 'false') === 'true' + && DbnBedrockModelRouter::supportsThinking($this->azure->chatDeployment()) + ) { + // Extended thinking — Pro showcase + $thinkResult = $this->azure->chatWithThinking($messages, [ + 'max_tokens' => 16000, + 'thinking_budget'=> (int)dbnToolsEnv('DBN_BEDROCK_THINKING_BUDGET', '8000'), + 'timeout' => 300, + ]); + $raw = $thinkResult['text']; + $thinkingTrace = $thinkResult['thinking'] ?? null; + $deployLabel = 'Claude 3.5 Sonnet (extended thinking)'; + } else { + $raw = $this->azure->chatText($messages, $opts); + $thinkingTrace = null; + $deployLabel = $this->azure instanceof DbnBedrockGateway + ? 'Claude 3.5 Sonnet' + : $this->azure->chatDeployment(); + } } else { $raw = $this->azure->chatText($messages, $opts); $deployLabel = $this->azure->chatDeployment(); @@ -1157,8 +1184,9 @@ PROMPT; } return [ - 'json' => $json, - 'deploy_label' => $deployLabel, + 'json' => $json, + 'deploy_label' => $deployLabel, + 'thinking_trace'=> $thinkingTrace, ]; } diff --git a/includes/DiscrepancyAgent.php b/includes/DiscrepancyAgent.php index 089dd7f..9808cd0 100644 --- a/includes/DiscrepancyAgent.php +++ b/includes/DiscrepancyAgent.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/AzureOpenAiGateway.php'; +require_once __DIR__ . '/DbnGatewayFactory.php'; /** * Document Discrepancy Finder Agent @@ -24,12 +25,12 @@ final class DbnDiscrepancyAgent private const MAX_DOC_CHARS = 64000; private const POOL_CAP = 20; - private DbnAzureOpenAiGateway $azure; + private DbnAzureOpenAiGateway|DbnBedrockGateway $azure; private array $stepTimings = []; - public function __construct(?DbnAzureOpenAiGateway $azure = null) + public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null) { - $this->azure = $azure ?: new DbnAzureOpenAiGateway(); + $this->azure = $azure ?: DbnGatewayFactory::makeForTool('discrepancy-find'); } /** diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php index a99954f..c4729d6 100644 --- a/includes/KorrespondAgent.php +++ b/includes/KorrespondAgent.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/AzureOpenAiGateway.php'; +require_once __DIR__ . '/DbnGatewayFactory.php'; /** * Korrespond — drafts replies or new correspondence to Norwegian authorities @@ -56,11 +57,11 @@ final class DbnKorrespondAgent 'other' => 'mottaker', ]; - private DbnAzureOpenAiGateway $azure; + private DbnAzureOpenAiGateway|DbnBedrockGateway $azure; - public function __construct(?DbnAzureOpenAiGateway $azure = null) + public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null) { - $this->azure = $azure ?: new DbnAzureOpenAiGateway(); + $this->azure = $azure ?: DbnGatewayFactory::makeForTool('korrespond'); } /** Localized chrome/progress strings, keyed by user UI language. */ diff --git a/includes/LegalAnalysisAgent.php b/includes/LegalAnalysisAgent.php index 0f9e3e5..ab3b7b6 100644 --- a/includes/LegalAnalysisAgent.php +++ b/includes/LegalAnalysisAgent.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/AzureOpenAiGateway.php'; +require_once __DIR__ . '/DbnGatewayFactory.php'; require_once __DIR__ . '/LegalTools.php'; /** @@ -22,12 +23,15 @@ final class DbnLegalAnalysisAgent private const LEGAL_TIMEOUT = 60; private const LEGAL_MODEL = 'dbn-legal-agent-v3'; - private DbnAzureOpenAiGateway $azureMini; + private DbnAzureOpenAiGateway|DbnBedrockGateway $azureMini; private DbnLegalToolsService $legalSvc; public function __construct() { - $this->azureMini = (new DbnAzureOpenAiGateway())->withDeployment('gpt-4o-mini'); + // On Azure: gpt-4o-mini for extraction/synthesis. On Bedrock: factory picks Haiku/Sonnet. + $this->azureMini = DbnGatewayFactory::bedrockEnabled() + ? DbnGatewayFactory::makeForTool('legal-analysis') + : (new DbnAzureOpenAiGateway())->withDeployment('gpt-4o-mini'); $this->legalSvc = new DbnLegalToolsService(); } diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 387afb1..f0571d3 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -3,17 +3,18 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/AzureOpenAiGateway.php'; +require_once __DIR__ . '/DbnGatewayFactory.php'; final class DbnLegalToolsService { private const MAX_PASTE_CHARS = 128000; private const MAX_TIMELINE_CHARS = 600000; - private DbnAzureOpenAiGateway $azure; + private DbnAzureOpenAiGateway|DbnBedrockGateway $azure; - public function __construct(?DbnAzureOpenAiGateway $azure = null) + public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null) { - $this->azure = $azure ?: new DbnAzureOpenAiGateway(); + $this->azure = $azure ?: DbnGatewayFactory::make(); } public function search( diff --git a/includes/ToolModels.php b/includes/ToolModels.php index 0621314..14f649d 100644 --- a/includes/ToolModels.php +++ b/includes/ToolModels.php @@ -21,7 +21,7 @@ final class ToolModels public static function engineForUser(int $userId, string $requestedEngine): string { - $valid = ['nova_lite', 'azure_mini', 'azure_full', 'gpu', 'regex']; + $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) { @@ -37,7 +37,7 @@ final class ToolModels public static function timelineRoute(int $userId, string $requestedEngine, string $text): array { - $valid = ['nova_lite', 'azure_mini', 'azure_full']; + $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'); @@ -122,60 +122,63 @@ final class ToolModels public static function timelineEngineLimit(string $engine): int { return match ($engine) { - 'nova_lite' => self::TIMELINE_QUICK_CHAR_LIMIT, - 'azure_mini' => self::TIMELINE_STANDARD_CHAR_LIMIT, - default => self::TIMELINE_DEEP_CHAR_LIMIT, + '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' => 10000, - 'azure_mini' => 16000, - default => 30000, + 'nova_lite', 'claude_haiku' => 10000, + 'azure_mini' => 16000, + default => 30000, }; } public static function timelineEngineMaxChars(string $engine): int { return match ($engine) { - 'nova_lite' => self::TIMELINE_QUICK_MAX_CHARS, - 'azure_mini' => self::TIMELINE_STANDARD_MAX_CHARS, - default => self::TIMELINE_DEEP_MAX_CHARS, + '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' => $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), + '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 $engine === 'azure_full' ? 2 : 1; + return in_array($engine, ['azure_full', 'claude_sonnet'], true) ? 2 : 1; } public static function timelineEngineLabel(string $engine): string { return match ($engine) { - 'nova_lite' => 'Quick', - 'azure_full' => 'Deep', - default => 'Standard', + 'nova_lite', 'claude_haiku' => 'Quick', + 'azure_full', 'claude_sonnet' => 'Deep', + default => 'Standard', }; } private static function timelineEngineRank(string $engine): int { return match ($engine) { - 'nova_lite' => 1, - 'azure_mini' => 2, - 'azure_full' => 3, - default => 0, + 'nova_lite', 'claude_haiku' => 1, + 'azure_mini' => 2, + 'azure_full', 'claude_sonnet' => 3, + default => 0, }; } }