{"repo": "acumenus-data-sciences/parthenon", "files": {"backend/app/Services/AI/AnalyticsLlmService.php": {"408145def1842fda33f0c07739ea6c7033d00d39": {"t": 1785746144.9670768, "n": 8262, "h": "72abfc4f596b8169", "c": " $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n *\n * @throws AiProviderNotConfiguredException\n * @throws AiProviderRequestException\n */\n public function chat(array $messages, array $options = []): string\n {\n $provider = AiProviderSetting::where('is_active', true)->first();\n\n if (! $provider || ! $provider->is_enabled) {\n throw new AiProviderNotConfiguredException;\n }\n\n /** @var array $settings */\n $settings = $provider->settings ?? [];\n $apiKey = $settings['api_key'] ?? '';\n $baseUrl = $settings['base_url'] ?? '';\n $model = $provider->model;\n\n if ($provider->provider_type !== 'ollama' && empty($apiKey)) {\n throw new AiProviderNotConfiguredException(\n \"API key not configured for {$provider->display_name}. Add it in System Health > AI Providers.\"\n );\n }\n\n return match ($provider->provider_type) {\n 'anthropic' => $this->callAnthropic($apiKey, $model, $messages, $options),\n 'openai', 'deepseek', 'moonshot', 'mistral' => $this->callOpenAiCompatible(\n $provider->provider_type, $apiKey, $model, $messages, $options, $baseUrl,\n ),\n 'gemini' => $this->callGemini($apiKey, $model, $messages, $options),\n 'qwen' => $this->callOpenAiCompatible(\n 'qwen', $apiKey, $model, $messages, $options, 'https://dashscope.aliyuncs.com/compatible-mode',\n ),\n 'ollama' => $this->callOllama($baseUrl ?: 'http://host.docker.internal:11434', $model, $messages, $options),\n default => throw new AiProviderRequestException(\"Unsupported provider: {$provider->provider_type}\", $provider->provider_type),\n };\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callAnthropic(string $apiKey, string $model, array $messages, array $options): string\n {\n $system = $options['system'] ?? null;\n\n $body = [\n 'model' => $model,\n 'max_tokens' => $options['max_tokens'] ?? 4096,\n 'messages' => $messages,\n ];\n\n if ($system) {\n $body['system'] = $system;\n }\n\n if (isset($options['temperature'])) {\n $body['temperature'] = $options['temperature'];\n }\n\n $response = Http::timeout(120)\n ->withHeaders([\n 'x-api-key' => $apiKey,\n 'anthropic-version' => '2023-06-01',\n 'content-type' => 'application/json',\n ])\n ->post('https://api.anthropic.com/v1/messages', $body);\n\n if (! $response->successful()) {\n Log::warning('Anthropic API error', [\n 'status' => $response->status(),\n 'body' => $response->body(),\n ]);\n throw new AiProviderRequestException(\n \"Anthropic API returned HTTP {$response->status()}: \".($response->json('error.message') ?? $response->body()),\n 'anthropic',\n $response->status(),\n );\n }\n\n /** @var array $content */\n $content = $response->json('content', []);\n\n return collect($content)\n ->where('type', 'text')\n ->pluck('text')\n ->implode('');\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callOpenAiCompatible(string $providerType, string $apiKey, string $model, array $messages, array $options, string $baseUrl = ''): string\n {\n $url = match ($providerType) {\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n 'moonshot' => 'https://api.moonshot.cn/v1/chat/completions',\n 'mistral' => 'https://api.mistral.ai/v1/chat/completions',\n default => rtrim($baseUrl, '/').'/v1/chat/completions',\n };\n\n $allMessages = $messages;\n if (! empty($options['system'])) {\n array_unshift($allMessages, ['role' => 'system', 'content' => $options['system']]);\n }\n\n $body = [\n 'model' => $model,\n 'messages' => $allMessages,\n 'max_tokens' => $options['max_tokens'] ?? 4096,\n ];\n\n if (isset($options['temperature'])) {\n $body['temperature'] = $options['temperature'];\n }\n\n $response = Http::timeout(120)\n ->withToken($apiKey)\n ->post($url, $body);\n\n if (! $response->successful()) {\n throw new AiProviderRequestException(\n \"{$providerType} API returned HTTP {$response->status()}: \".($response->json('error.message') ?? $response->body()),\n $providerType,\n $response->status(),\n );\n }\n\n return $response->json('choices.0.message.content', '');\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callGemini(string $apiKey, string $model, array $messages, array $options): string\n {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n $contents = [];\n foreach ($messages as $msg) {\n $contents[] = [\n 'role' => $msg['role'] === 'assistant' ? 'model' : 'user',\n 'parts' => [['text' => $msg['content']]],\n ];\n }\n\n $body = ['contents' => $contents];\n\n if (! empty($options['system'])) {\n $body['systemInstruction'] = [\n 'parts' => [['text' => $options['system']]],\n ];\n }\n\n if (isset($options['temperature'])) {\n $body['generationConfig']['temperature'] = $options['temperature'];\n }\n\n if (isset($options['max_tokens'])) {\n $body['generationConfig']['maxOutputTokens'] = $options['max_tokens'];\n }\n\n $response = Http::timeout(120)\n ->post($url, $body);\n\n if (! $response->successful()) {\n throw new AiProviderRequestException(\n \"Gemini API returned HTTP {$response->status()}: \".$response->body(),\n 'gemini',\n $response->status(),\n );\n }\n\n return $response->json('candidates.0.content.parts.0.text', '');\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callOllama(string $baseUrl, string $model, array $messages, array $options): string\n {\n $allMessages = $messages;\n if (! empty($options['system'])) {\n array_unshift($allMessages, ['role' => 'system', 'content' => $options['system']]);\n }\n\n $response = Http::timeout(120)\n ->post(rtrim($baseUrl, '/').'/api/chat', [\n 'model' => $model,\n 'messages' => $allMessages,\n 'stream' => false,\n ]);\n\n if (! $response->successful()) {\n throw new AiProviderRequestException(\n \"Ollama returned HTTP {$response->status()}: \".$response->body(),\n 'ollama',\n $response->status(),\n );\n }\n\n return $response->json('message.content', '');\n }\n}\n"}, "7e988c1fc0bf1fd6980cecff6cb7f3392a912010": {"t": 1785760151.6065152, "n": 8262, "h": "72abfc4f596b8169", "c": " $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n *\n * @throws AiProviderNotConfiguredException\n * @throws AiProviderRequestException\n */\n public function chat(array $messages, array $options = []): string\n {\n $provider = AiProviderSetting::where('is_active', true)->first();\n\n if (! $provider || ! $provider->is_enabled) {\n throw new AiProviderNotConfiguredException;\n }\n\n /** @var array $settings */\n $settings = $provider->settings ?? [];\n $apiKey = $settings['api_key'] ?? '';\n $baseUrl = $settings['base_url'] ?? '';\n $model = $provider->model;\n\n if ($provider->provider_type !== 'ollama' && empty($apiKey)) {\n throw new AiProviderNotConfiguredException(\n \"API key not configured for {$provider->display_name}. Add it in System Health > AI Providers.\"\n );\n }\n\n return match ($provider->provider_type) {\n 'anthropic' => $this->callAnthropic($apiKey, $model, $messages, $options),\n 'openai', 'deepseek', 'moonshot', 'mistral' => $this->callOpenAiCompatible(\n $provider->provider_type, $apiKey, $model, $messages, $options, $baseUrl,\n ),\n 'gemini' => $this->callGemini($apiKey, $model, $messages, $options),\n 'qwen' => $this->callOpenAiCompatible(\n 'qwen', $apiKey, $model, $messages, $options, 'https://dashscope.aliyuncs.com/compatible-mode',\n ),\n 'ollama' => $this->callOllama($baseUrl ?: 'http://host.docker.internal:11434', $model, $messages, $options),\n default => throw new AiProviderRequestException(\"Unsupported provider: {$provider->provider_type}\", $provider->provider_type),\n };\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callAnthropic(string $apiKey, string $model, array $messages, array $options): string\n {\n $system = $options['system'] ?? null;\n\n $body = [\n 'model' => $model,\n 'max_tokens' => $options['max_tokens'] ?? 4096,\n 'messages' => $messages,\n ];\n\n if ($system) {\n $body['system'] = $system;\n }\n\n if (isset($options['temperature'])) {\n $body['temperature'] = $options['temperature'];\n }\n\n $response = Http::timeout(120)\n ->withHeaders([\n 'x-api-key' => $apiKey,\n 'anthropic-version' => '2023-06-01',\n 'content-type' => 'application/json',\n ])\n ->post('https://api.anthropic.com/v1/messages', $body);\n\n if (! $response->successful()) {\n Log::warning('Anthropic API error', [\n 'status' => $response->status(),\n 'body' => $response->body(),\n ]);\n throw new AiProviderRequestException(\n \"Anthropic API returned HTTP {$response->status()}: \".($response->json('error.message') ?? $response->body()),\n 'anthropic',\n $response->status(),\n );\n }\n\n /** @var array $content */\n $content = $response->json('content', []);\n\n return collect($content)\n ->where('type', 'text')\n ->pluck('text')\n ->implode('');\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callOpenAiCompatible(string $providerType, string $apiKey, string $model, array $messages, array $options, string $baseUrl = ''): string\n {\n $url = match ($providerType) {\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n 'moonshot' => 'https://api.moonshot.cn/v1/chat/completions',\n 'mistral' => 'https://api.mistral.ai/v1/chat/completions',\n default => rtrim($baseUrl, '/').'/v1/chat/completions',\n };\n\n $allMessages = $messages;\n if (! empty($options['system'])) {\n array_unshift($allMessages, ['role' => 'system', 'content' => $options['system']]);\n }\n\n $body = [\n 'model' => $model,\n 'messages' => $allMessages,\n 'max_tokens' => $options['max_tokens'] ?? 4096,\n ];\n\n if (isset($options['temperature'])) {\n $body['temperature'] = $options['temperature'];\n }\n\n $response = Http::timeout(120)\n ->withToken($apiKey)\n ->post($url, $body);\n\n if (! $response->successful()) {\n throw new AiProviderRequestException(\n \"{$providerType} API returned HTTP {$response->status()}: \".($response->json('error.message') ?? $response->body()),\n $providerType,\n $response->status(),\n );\n }\n\n return $response->json('choices.0.message.content', '');\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callGemini(string $apiKey, string $model, array $messages, array $options): string\n {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n $contents = [];\n foreach ($messages as $msg) {\n $contents[] = [\n 'role' => $msg['role'] === 'assistant' ? 'model' : 'user',\n 'parts' => [['text' => $msg['content']]],\n ];\n }\n\n $body = ['contents' => $contents];\n\n if (! empty($options['system'])) {\n $body['systemInstruction'] = [\n 'parts' => [['text' => $options['system']]],\n ];\n }\n\n if (isset($options['temperature'])) {\n $body['generationConfig']['temperature'] = $options['temperature'];\n }\n\n if (isset($options['max_tokens'])) {\n $body['generationConfig']['maxOutputTokens'] = $options['max_tokens'];\n }\n\n $response = Http::timeout(120)\n ->post($url, $body);\n\n if (! $response->successful()) {\n throw new AiProviderRequestException(\n \"Gemini API returned HTTP {$response->status()}: \".$response->body(),\n 'gemini',\n $response->status(),\n );\n }\n\n return $response->json('candidates.0.content.parts.0.text', '');\n }\n\n /**\n * @param array $messages\n * @param array{system?: string, max_tokens?: int, temperature?: float} $options\n */\n private function callOllama(string $baseUrl, string $model, array $messages, array $options): string\n {\n $allMessages = $messages;\n if (! empty($options['system'])) {\n array_unshift($allMessages, ['role' => 'system', 'content' => $options['system']]);\n }\n\n $response = Http::timeout(120)\n ->post(rtrim($baseUrl, '/').'/api/chat', [\n 'model' => $model,\n 'messages' => $allMessages,\n 'stream' => false,\n ]);\n\n if (! $response->successful()) {\n throw new AiProviderRequestException(\n \"Ollama returned HTTP {$response->status()}: \".$response->body(),\n 'ollama',\n $response->status(),\n );\n }\n\n return $response->json('message.content', '');\n }\n}\n"}}, "backend/app/Services/AI/AbbyProviderPolicyService.php": {"408145def1842fda33f0c07739ea6c7033d00d39": {"t": 1785746172.791125, "n": 20269, "h": "7d08d813a16da0a6", "c": " 'https://api.deepseek.com',\n 'mistral' => 'https://api.mistral.ai/v1',\n 'moonshot' => 'https://api.moonshot.cn/v1',\n 'qwen' => 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n ];\n\n /**\n * @return array>\n */\n public function surfaceRequirements(): array\n {\n return [\n 'chat' => ['chat', 'streaming'],\n 'parse_cohort' => ['chat'],\n 'study_agent' => ['agent_loop', 'tool_calling'],\n 'protocol_evaluator' => ['chat'],\n 'gis' => ['chat'],\n 'phenotype_interpreter' => ['chat'],\n 'embeddings' => ['embeddings'],\n ];\n }\n\n /**\n * @return array\n */\n public function catalog(): array\n {\n return [\n 'capabilities' => self::CAPABILITIES,\n 'entitlements' => self::ENTITLEMENTS,\n 'modes' => self::MODES,\n 'surfaces' => self::SURFACES,\n 'transports' => self::TRANSPORTS,\n 'surface_requirements' => $this->surfaceRequirements(),\n 'presets' => $this->presets(),\n 'readiness' => $this->readiness(),\n ];\n }\n\n /**\n * Per-profile readiness states for admin diagnostics. Each configured profile\n * maps to one of: ready | missing_key | local_model_unconfigured |\n * unsupported_for_agent | disabled. Secret-safe (no key material).\n *\n * @return array>\n */\n public function readiness(): array\n {\n if (! $this->tablesExist()) {\n return [];\n }\n\n return AbbyProviderProfile::query()\n ->orderBy('profile_id')\n ->get()\n ->map(function (AbbyProviderProfile $profile): array {\n return [\n 'profile_id' => $profile->profile_id,\n 'provider_type' => $profile->provider_type,\n 'transport' => $profile->transport,\n 'entitlement_type' => $profile->entitlement_type,\n 'state' => $this->readinessState($profile),\n 'agent_capable' => in_array('agent_loop', array_values($profile->capabilities ?? []), true)\n && in_array('tool_calling', array_values($profile->capabilities ?? []), true),\n ];\n })\n ->all();\n }\n\n private function readinessState(AbbyProviderProfile $profile): string\n {\n if (! $profile->is_enabled) {\n return 'disabled';\n }\n\n if ($profile->transport === 'external_subscription_app') {\n return 'external_subscription_app';\n }\n\n if ($this->isCloudProfile($profile)) {\n $settings = $this->providerSettingsForProfile($profile);\n\n return ! empty($settings['api_key']) ? 'ready' : 'missing_key';\n }\n\n // Local profile: ready if a model is configured (live Ollama probe is the\n // python-ai /abby/provider-health responsibility, surfaced separately).\n return $profile->model ? 'ready' : 'local_model_unconfigured';\n }\n\n /**\n * @param array $attributes\n * @return array\n */\n public function validateProfileAttributes(array $attributes): array\n {\n $errors = [];\n $transport = (string) ($attributes['transport'] ?? '');\n $entitlement = (string) ($attributes['entitlement_type'] ?? 'local');\n $providerType = (string) ($attributes['provider_type'] ?? '');\n $capabilities = array_values($attributes['capabilities'] ?? []);\n\n $unknownCapabilities = array_values(array_diff($capabilities, self::CAPABILITIES));\n if ($unknownCapabilities !== []) {\n $errors[] = 'unknown_capabilities:'.implode(',', $unknownCapabilities);\n }\n\n if (! in_array($transport, self::TRANSPORTS, true)) {\n $errors[] = 'unsupported_transport';\n }\n\n if (! in_array($entitlement, self::ENTITLEMENTS, true)) {\n $errors[] = 'unsupported_entitlement';\n }\n\n if ($transport === 'ollama_chat' && empty($attributes['base_url'])) {\n $errors[] = 'base_url_required_for_ollama';\n }\n\n if ($transport === 'openai_compatible_chat' && empty($attributes['base_url'])) {\n $errors[] = 'base_url_required_for_openai_compatible';\n }\n\n if ($transport !== 'ollama_chat' && $entitlement === 'local') {\n $errors[] = 'cloud_transport_requires_non_local_entitlement';\n }\n\n if ($transport === 'ollama_chat' && $entitlement !== 'local') {\n $errors[] = 'ollama_transport_requires_local_entitlement';\n }\n\n if ($providerType === 'openai_compatible' && $transport === 'openai_compatible_chat') {\n $errors[] = 'openai_compatible_profile_requires_concrete_provider_type';\n }\n\n return $errors;\n }\n\n /**\n * @return array\n */\n public function validateSurfacePolicy(AbbySurfacePolicy $policy): array\n {\n $errors = [];\n\n if (! in_array($policy->surface, self::SURFACES, true)) {\n $errors[] = 'unsupported_surface';\n }\n\n if (! in_array($policy->provider_mode, self::MODES, true)) {\n $errors[] = 'unsupported_provider_mode';\n }\n\n if ($policy->provider_mode === 'disabled') {\n return $errors;\n }\n\n $profile = $policy->default_profile_id\n ? AbbyProviderProfile::where('profile_id', $policy->default_profile_id)->first()\n : null;\n\n if ($profile === null) {\n $errors[] = 'default_profile_missing';\n } else {\n $errors = array_merge($errors, $this->validateProfileForSurface($profile, $policy));\n }\n\n foreach (($policy->fallback_profile_ids ?? []) as $profileId) {\n $fallback = AbbyProviderProfile::where('profile_id', $profileId)->first();\n if ($fallback === null) {\n $errors[] = \"fallback_profile_missing:{$profileId}\";\n } else {\n $errors = array_merge($errors, array_map(\n fn (string $error): string => \"fallback_profile_invalid:{$profileId}:{$error}\",\n $this->validateProfileForSurface($fallback, $policy),\n ));\n }\n }\n\n return $errors;\n }\n\n /**\n * @return array\n */\n public function validateProfileForSurface(AbbyProviderProfile $profile, AbbySurfacePolicy $policy): array\n {\n $errors = [];\n $capabilities = array_values($profile->capabilities ?? []);\n $required = array_values($policy->required_capabilities ?: ($this->surfaceRequirements()[$policy->surface] ?? ['chat']));\n $missing = array_values(array_diff($required, $capabilities));\n\n if (! $profile->is_enabled) {\n $errors[] = 'profile_disabled';\n }\n\n if ($profile->transport === 'external_subscription_app') {\n $errors[] = 'external_subscription_app_not_backend_routable';\n }\n\n if ($missing !== []) {\n $errors[] = 'missing_capabilities:'.implode(',', $missing);\n }\n\n if ($this->isCloudProfile($profile) && ! $policy->allow_cloud) {\n $errors[] = 'cloud_not_allowed';\n }\n\n $patientLevelAllowed = (bool) Arr::get($profile->safety ?? [], 'patient_level_context_allowed', false);\n if (\n $this->isCloudProfile($profile)\n && $policy->never_send_phi_to_cloud\n && ! $patientLevelAllowed\n ) {\n $errors[] = 'patient_level_context_not_cloud_safe';\n }\n\n return $errors;\n }\n\n /**\n * @return array|null\n */\n public function payloadForSurface(string $surface): ?array\n {\n if (! $this->tablesExist()) {\n return null;\n }\n\n $policy = AbbySurfacePolicy::where('surface', $surface)->first();\n if ($policy === null) {\n return null;\n }\n\n if ($policy->provider_mode === 'disabled') {\n return [\n 'provider_type' => 'ollama',\n 'profile_id' => $policy->default_profile_id ?: 'local-medgemma',\n 'mode' => 'disabled',\n 'model' => '',\n 'entitlement' => 'local',\n 'settings' => [],\n ];\n }\n\n // Resolve in configured order: default profile first, then each fallback,\n // returning the first profile that is valid for this surface. This mirrors\n // the route simulator so the production payload path and the simulator\n // agree on which profile a turn will actually use.\n $candidateIds = array_merge(\n $policy->default_profile_id ? [$policy->default_profile_id] : [],\n $policy->fallback_profile_ids ?? [],\n );\n\n foreach ($candidateIds as $profileId) {\n $profile = AbbyProviderProfile::where('profile_id', $profileId)->first();\n if ($profile !== null && $this->validateProfileForSurface($profile, $policy) === []) {\n return $this->payloadForProfile($profile, $policy->provider_mode);\n }\n }\n\n return null;\n }\n\n /**\n * Named policy presets that fill sensible defaults for common deployments.\n * Super-admins apply one as a starting point, then adjust. The presets do\n * not by themselves change runtime state — they describe a surface policy.\n *\n * @return array>\n */\n public function presets(): array\n {\n return [\n 'clinical_local_only' => [\n 'label' => 'Clinical local-only',\n 'provider_mode' => 'local_only',\n 'allow_cloud' => false,\n 'never_send_phi_to_cloud' => true,\n ],\n 'local_first_cloud_summaries' => [\n 'label' => 'Local-first with cloud summaries',\n 'provider_mode' => 'local_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n ],\n 'cloud_first_phi_block' => [\n 'label' => 'Cloud-first with PHI block',\n 'provider_mode' => 'cloud_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n ],\n 'byo_api_key' => [\n 'label' => 'BYO API key',\n 'provider_mode' => 'cloud_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n 'entitlement' => 'user_api_key',\n ],\n 'external_assistant_tools_only' => [\n 'label' => 'External assistant tools only',\n 'provider_mode' => 'disabled',\n 'allow_cloud' => false,\n 'never_send_phi_to_cloud' => true,\n 'note' => 'Backend Abby disabled; external assistant uses approved read-only tools (not backend quota).',\n ],\n 'agents_read_only_local' => [\n 'label' => 'Agents read-only local',\n 'provider_mode' => 'local_only',\n 'allow_cloud' => false,\n 'never_send_phi_to_cloud' => true,\n 'surface' => 'study_agent',\n ],\n 'agents_cloud_with_approvals' => [\n 'label' => 'Agents cloud with approvals',\n 'provider_mode' => 'cloud_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n 'surface' => 'study_agent',\n ],\n ];\n }\n\n /**\n * @return array\n */\n public function payloadForProfile(AbbyProviderProfile $profile, string $mode): array\n {\n $providerSettings = $this->providerSettingsForProfile($profile);\n $limits = $profile->limits ?? [];\n $baseUrl = $profile->base_url\n ?: ($providerSettings['base_url'] ?? null)\n ?: self::OPENAI_COMPATIBLE_BASE_URLS[strtolower($profile->provider_type)] ?? null;\n\n return [\n 'provider_type' => strtolower($profile->provider_type),\n 'profile_id' => $profile->profile_id,\n 'mode' => $mode,\n 'model' => $profile->model,\n 'entitlement' => $profile->entitlement_type,\n 'settings' => $this->onlyNonEmpty([\n 'api_key' => $providerSettings['api_key'] ?? null,\n 'base_url' => $baseUrl,\n 'timeout' => $limits['timeout'] ?? $limits['timeout_seconds'] ?? $providerSettings['timeout'] ?? null,\n 'max_output_tokens' => $limits['max_output_tokens'] ?? $providerSettings['max_output_tokens'] ?? null,\n 'monthly_budget_usd' => $limits['monthly_budget_usd'] ?? $providerSettings['monthly_budget_usd'] ?? null,\n 'entitlement' => $profile->entitlement_type,\n ]),\n ];\n }\n\n /**\n * @param array $request\n * @return array\n */\n public function simulateRoute(array $request): array\n {\n $surface = (string) ($request['surface'] ?? 'chat');\n $message = (string) ($request['message'] ?? '');\n $policy = AbbySurfacePolicy::where('surface', $surface)->first();\n\n if ($policy === null) {\n return [\n 'configured' => false,\n 'surface' => $surface,\n 'reason' => 'surface_policy_missing',\n 'will_call_paid_provider' => false,\n 'blocked_reasons' => ['surface_policy_missing'],\n ];\n }\n\n if ($policy->provider_mode === 'disabled') {\n return [\n 'configured' => true,\n 'surface' => $surface,\n 'provider_mode' => $policy->provider_mode,\n 'reason' => 'provider_disabled',\n 'will_call_paid_provider' => false,\n 'blocked_reasons' => ['provider_disabled'],\n ];\n }\n\n $profile = AbbyProviderProfile::where('profile_id', (string) $policy->default_profile_id)->first();\n $blockedReasons = $profile === null\n ? ['default_profile_missing']\n : $this->validateProfileForSurface($profile, $policy);\n $selectedProfile = $profile;\n $fallbackUsed = false;\n\n if ($blockedReasons !== []) {\n foreach (($policy->fallback_profile_ids ?? []) as $profileId) {\n $candidate = AbbyProviderProfile::where('profile_id', $profileId)->first();\n if ($candidate !== null && $this->validateProfileForSurface($candidate, $policy) === []) {\n $selectedProfile = $candidate;\n $fallbackUsed = true;\n break;\n }\n }\n }\n\n return [\n 'configured' => true,\n 'surface' => $surface,\n 'provider_mode' => $policy->provider_mode,\n 'message_length' => strlen($message),\n 'requested_profile_id' => $policy->default_profile_id,\n 'selected_profile' => $selectedProfile?->only([\n 'profile_id',\n 'display_name',\n 'provider_type',\n 'transport',\n 'entitlement_type',\n 'model',\n 'is_enabled',\n ]),\n 'fallback_profile_ids' => $policy->fallback_profile_ids ?? [],\n 'fallback_used' => $fallbackUsed,\n 'reason' => $blockedReasons === [] ? $policy->provider_mode : $blockedReasons[0],\n 'blocked_reasons' => $blockedReasons,\n 'will_call_paid_provider' => $selectedProfile !== null\n && $this->isCloudProfile($selectedProfile)\n && $blockedReasons === [],\n 'estimated_budget_impact' => $selectedProfile !== null && $this->isCloudProfile($selectedProfile)\n ? 'unknown_until_provider_response'\n : 'zero_api_cost',\n ];\n }\n\n public function isCloudProfile(AbbyProviderProfile $profile): bool\n {\n return $profile->entitlement_type !== 'local' && $profile->transport !== 'ollama_chat';\n }\n\n /**\n * @return array\n */\n public function profilePolicyReferences(string $profileId): array\n {\n return AbbySurfacePolicy::query()\n ->orderBy('surface')\n ->get()\n ->flatMap(function (AbbySurfacePolicy $policy) use ($profileId): array {\n $references = [];\n\n if ($policy->default_profile_id === $profileId) {\n $references[] = [\n 'surface' => $policy->surface,\n 'role' => 'default_profile_id',\n ];\n }\n\n foreach (($policy->fallback_profile_ids ?? []) as $index => $fallbackProfileId) {\n if ((string) $fallbackProfileId === $profileId) {\n $references[] = [\n 'surface' => $policy->surface,\n 'role' => 'fallback_profile_ids',\n 'index' => $index,\n ];\n }\n }\n\n return $references;\n })\n ->values()\n ->all();\n }\n\n public function profileIsReferenced(string $profileId): bool\n {\n return $this->profilePolicyReferences($profileId) !== [];\n }\n\n private function tablesExist(): bool\n {\n return Schema::hasTable('abby_provider_profiles') && Schema::hasTable('abby_surface_policies');\n }\n\n /**\n * @return array\n */\n private function providerSettingsForProfile(AbbyProviderProfile $profile): array\n {\n $providerType = $profile->provider_setting_type ?: $profile->provider_type;\n $provider = AiProviderSetting::where('provider_type', $providerType)\n ->where('is_enabled', true)\n ->first();\n\n return $provider?->settings ?? [];\n }\n\n /**\n * @param array $values\n * @return array\n */\n private function onlyNonEmpty(array $values): array\n {\n return array_filter(\n $values,\n fn ($value): bool => $value !== null && $value !== '',\n );\n }\n\n /**\n * @param array $errors\n *\n * @throws ValidationException\n */\n public function throwIfErrors(array $errors, string $key = 'policy'): void\n {\n if ($errors !== []) {\n throw ValidationException::withMessages([$key => $errors]);\n }\n }\n}\n"}, "a516e5a39f290228e97db43d0ddecd254e2bce49": {"t": 1785760149.2219934, "n": 20269, "h": "7d08d813a16da0a6", "c": " 'https://api.deepseek.com',\n 'mistral' => 'https://api.mistral.ai/v1',\n 'moonshot' => 'https://api.moonshot.cn/v1',\n 'qwen' => 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n ];\n\n /**\n * @return array>\n */\n public function surfaceRequirements(): array\n {\n return [\n 'chat' => ['chat', 'streaming'],\n 'parse_cohort' => ['chat'],\n 'study_agent' => ['agent_loop', 'tool_calling'],\n 'protocol_evaluator' => ['chat'],\n 'gis' => ['chat'],\n 'phenotype_interpreter' => ['chat'],\n 'embeddings' => ['embeddings'],\n ];\n }\n\n /**\n * @return array\n */\n public function catalog(): array\n {\n return [\n 'capabilities' => self::CAPABILITIES,\n 'entitlements' => self::ENTITLEMENTS,\n 'modes' => self::MODES,\n 'surfaces' => self::SURFACES,\n 'transports' => self::TRANSPORTS,\n 'surface_requirements' => $this->surfaceRequirements(),\n 'presets' => $this->presets(),\n 'readiness' => $this->readiness(),\n ];\n }\n\n /**\n * Per-profile readiness states for admin diagnostics. Each configured profile\n * maps to one of: ready | missing_key | local_model_unconfigured |\n * unsupported_for_agent | disabled. Secret-safe (no key material).\n *\n * @return array>\n */\n public function readiness(): array\n {\n if (! $this->tablesExist()) {\n return [];\n }\n\n return AbbyProviderProfile::query()\n ->orderBy('profile_id')\n ->get()\n ->map(function (AbbyProviderProfile $profile): array {\n return [\n 'profile_id' => $profile->profile_id,\n 'provider_type' => $profile->provider_type,\n 'transport' => $profile->transport,\n 'entitlement_type' => $profile->entitlement_type,\n 'state' => $this->readinessState($profile),\n 'agent_capable' => in_array('agent_loop', array_values($profile->capabilities ?? []), true)\n && in_array('tool_calling', array_values($profile->capabilities ?? []), true),\n ];\n })\n ->all();\n }\n\n private function readinessState(AbbyProviderProfile $profile): string\n {\n if (! $profile->is_enabled) {\n return 'disabled';\n }\n\n if ($profile->transport === 'external_subscription_app') {\n return 'external_subscription_app';\n }\n\n if ($this->isCloudProfile($profile)) {\n $settings = $this->providerSettingsForProfile($profile);\n\n return ! empty($settings['api_key']) ? 'ready' : 'missing_key';\n }\n\n // Local profile: ready if a model is configured (live Ollama probe is the\n // python-ai /abby/provider-health responsibility, surfaced separately).\n return $profile->model ? 'ready' : 'local_model_unconfigured';\n }\n\n /**\n * @param array $attributes\n * @return array\n */\n public function validateProfileAttributes(array $attributes): array\n {\n $errors = [];\n $transport = (string) ($attributes['transport'] ?? '');\n $entitlement = (string) ($attributes['entitlement_type'] ?? 'local');\n $providerType = (string) ($attributes['provider_type'] ?? '');\n $capabilities = array_values($attributes['capabilities'] ?? []);\n\n $unknownCapabilities = array_values(array_diff($capabilities, self::CAPABILITIES));\n if ($unknownCapabilities !== []) {\n $errors[] = 'unknown_capabilities:'.implode(',', $unknownCapabilities);\n }\n\n if (! in_array($transport, self::TRANSPORTS, true)) {\n $errors[] = 'unsupported_transport';\n }\n\n if (! in_array($entitlement, self::ENTITLEMENTS, true)) {\n $errors[] = 'unsupported_entitlement';\n }\n\n if ($transport === 'ollama_chat' && empty($attributes['base_url'])) {\n $errors[] = 'base_url_required_for_ollama';\n }\n\n if ($transport === 'openai_compatible_chat' && empty($attributes['base_url'])) {\n $errors[] = 'base_url_required_for_openai_compatible';\n }\n\n if ($transport !== 'ollama_chat' && $entitlement === 'local') {\n $errors[] = 'cloud_transport_requires_non_local_entitlement';\n }\n\n if ($transport === 'ollama_chat' && $entitlement !== 'local') {\n $errors[] = 'ollama_transport_requires_local_entitlement';\n }\n\n if ($providerType === 'openai_compatible' && $transport === 'openai_compatible_chat') {\n $errors[] = 'openai_compatible_profile_requires_concrete_provider_type';\n }\n\n return $errors;\n }\n\n /**\n * @return array\n */\n public function validateSurfacePolicy(AbbySurfacePolicy $policy): array\n {\n $errors = [];\n\n if (! in_array($policy->surface, self::SURFACES, true)) {\n $errors[] = 'unsupported_surface';\n }\n\n if (! in_array($policy->provider_mode, self::MODES, true)) {\n $errors[] = 'unsupported_provider_mode';\n }\n\n if ($policy->provider_mode === 'disabled') {\n return $errors;\n }\n\n $profile = $policy->default_profile_id\n ? AbbyProviderProfile::where('profile_id', $policy->default_profile_id)->first()\n : null;\n\n if ($profile === null) {\n $errors[] = 'default_profile_missing';\n } else {\n $errors = array_merge($errors, $this->validateProfileForSurface($profile, $policy));\n }\n\n foreach (($policy->fallback_profile_ids ?? []) as $profileId) {\n $fallback = AbbyProviderProfile::where('profile_id', $profileId)->first();\n if ($fallback === null) {\n $errors[] = \"fallback_profile_missing:{$profileId}\";\n } else {\n $errors = array_merge($errors, array_map(\n fn (string $error): string => \"fallback_profile_invalid:{$profileId}:{$error}\",\n $this->validateProfileForSurface($fallback, $policy),\n ));\n }\n }\n\n return $errors;\n }\n\n /**\n * @return array\n */\n public function validateProfileForSurface(AbbyProviderProfile $profile, AbbySurfacePolicy $policy): array\n {\n $errors = [];\n $capabilities = array_values($profile->capabilities ?? []);\n $required = array_values($policy->required_capabilities ?: ($this->surfaceRequirements()[$policy->surface] ?? ['chat']));\n $missing = array_values(array_diff($required, $capabilities));\n\n if (! $profile->is_enabled) {\n $errors[] = 'profile_disabled';\n }\n\n if ($profile->transport === 'external_subscription_app') {\n $errors[] = 'external_subscription_app_not_backend_routable';\n }\n\n if ($missing !== []) {\n $errors[] = 'missing_capabilities:'.implode(',', $missing);\n }\n\n if ($this->isCloudProfile($profile) && ! $policy->allow_cloud) {\n $errors[] = 'cloud_not_allowed';\n }\n\n $patientLevelAllowed = (bool) Arr::get($profile->safety ?? [], 'patient_level_context_allowed', false);\n if (\n $this->isCloudProfile($profile)\n && $policy->never_send_phi_to_cloud\n && ! $patientLevelAllowed\n ) {\n $errors[] = 'patient_level_context_not_cloud_safe';\n }\n\n return $errors;\n }\n\n /**\n * @return array|null\n */\n public function payloadForSurface(string $surface): ?array\n {\n if (! $this->tablesExist()) {\n return null;\n }\n\n $policy = AbbySurfacePolicy::where('surface', $surface)->first();\n if ($policy === null) {\n return null;\n }\n\n if ($policy->provider_mode === 'disabled') {\n return [\n 'provider_type' => 'ollama',\n 'profile_id' => $policy->default_profile_id ?: 'local-medgemma',\n 'mode' => 'disabled',\n 'model' => '',\n 'entitlement' => 'local',\n 'settings' => [],\n ];\n }\n\n // Resolve in configured order: default profile first, then each fallback,\n // returning the first profile that is valid for this surface. This mirrors\n // the route simulator so the production payload path and the simulator\n // agree on which profile a turn will actually use.\n $candidateIds = array_merge(\n $policy->default_profile_id ? [$policy->default_profile_id] : [],\n $policy->fallback_profile_ids ?? [],\n );\n\n foreach ($candidateIds as $profileId) {\n $profile = AbbyProviderProfile::where('profile_id', $profileId)->first();\n if ($profile !== null && $this->validateProfileForSurface($profile, $policy) === []) {\n return $this->payloadForProfile($profile, $policy->provider_mode);\n }\n }\n\n return null;\n }\n\n /**\n * Named policy presets that fill sensible defaults for common deployments.\n * Super-admins apply one as a starting point, then adjust. The presets do\n * not by themselves change runtime state — they describe a surface policy.\n *\n * @return array>\n */\n public function presets(): array\n {\n return [\n 'clinical_local_only' => [\n 'label' => 'Clinical local-only',\n 'provider_mode' => 'local_only',\n 'allow_cloud' => false,\n 'never_send_phi_to_cloud' => true,\n ],\n 'local_first_cloud_summaries' => [\n 'label' => 'Local-first with cloud summaries',\n 'provider_mode' => 'local_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n ],\n 'cloud_first_phi_block' => [\n 'label' => 'Cloud-first with PHI block',\n 'provider_mode' => 'cloud_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n ],\n 'byo_api_key' => [\n 'label' => 'BYO API key',\n 'provider_mode' => 'cloud_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n 'entitlement' => 'user_api_key',\n ],\n 'external_assistant_tools_only' => [\n 'label' => 'External assistant tools only',\n 'provider_mode' => 'disabled',\n 'allow_cloud' => false,\n 'never_send_phi_to_cloud' => true,\n 'note' => 'Backend Abby disabled; external assistant uses approved read-only tools (not backend quota).',\n ],\n 'agents_read_only_local' => [\n 'label' => 'Agents read-only local',\n 'provider_mode' => 'local_only',\n 'allow_cloud' => false,\n 'never_send_phi_to_cloud' => true,\n 'surface' => 'study_agent',\n ],\n 'agents_cloud_with_approvals' => [\n 'label' => 'Agents cloud with approvals',\n 'provider_mode' => 'cloud_first',\n 'allow_cloud' => true,\n 'never_send_phi_to_cloud' => true,\n 'surface' => 'study_agent',\n ],\n ];\n }\n\n /**\n * @return array\n */\n public function payloadForProfile(AbbyProviderProfile $profile, string $mode): array\n {\n $providerSettings = $this->providerSettingsForProfile($profile);\n $limits = $profile->limits ?? [];\n $baseUrl = $profile->base_url\n ?: ($providerSettings['base_url'] ?? null)\n ?: self::OPENAI_COMPATIBLE_BASE_URLS[strtolower($profile->provider_type)] ?? null;\n\n return [\n 'provider_type' => strtolower($profile->provider_type),\n 'profile_id' => $profile->profile_id,\n 'mode' => $mode,\n 'model' => $profile->model,\n 'entitlement' => $profile->entitlement_type,\n 'settings' => $this->onlyNonEmpty([\n 'api_key' => $providerSettings['api_key'] ?? null,\n 'base_url' => $baseUrl,\n 'timeout' => $limits['timeout'] ?? $limits['timeout_seconds'] ?? $providerSettings['timeout'] ?? null,\n 'max_output_tokens' => $limits['max_output_tokens'] ?? $providerSettings['max_output_tokens'] ?? null,\n 'monthly_budget_usd' => $limits['monthly_budget_usd'] ?? $providerSettings['monthly_budget_usd'] ?? null,\n 'entitlement' => $profile->entitlement_type,\n ]),\n ];\n }\n\n /**\n * @param array $request\n * @return array\n */\n public function simulateRoute(array $request): array\n {\n $surface = (string) ($request['surface'] ?? 'chat');\n $message = (string) ($request['message'] ?? '');\n $policy = AbbySurfacePolicy::where('surface', $surface)->first();\n\n if ($policy === null) {\n return [\n 'configured' => false,\n 'surface' => $surface,\n 'reason' => 'surface_policy_missing',\n 'will_call_paid_provider' => false,\n 'blocked_reasons' => ['surface_policy_missing'],\n ];\n }\n\n if ($policy->provider_mode === 'disabled') {\n return [\n 'configured' => true,\n 'surface' => $surface,\n 'provider_mode' => $policy->provider_mode,\n 'reason' => 'provider_disabled',\n 'will_call_paid_provider' => false,\n 'blocked_reasons' => ['provider_disabled'],\n ];\n }\n\n $profile = AbbyProviderProfile::where('profile_id', (string) $policy->default_profile_id)->first();\n $blockedReasons = $profile === null\n ? ['default_profile_missing']\n : $this->validateProfileForSurface($profile, $policy);\n $selectedProfile = $profile;\n $fallbackUsed = false;\n\n if ($blockedReasons !== []) {\n foreach (($policy->fallback_profile_ids ?? []) as $profileId) {\n $candidate = AbbyProviderProfile::where('profile_id', $profileId)->first();\n if ($candidate !== null && $this->validateProfileForSurface($candidate, $policy) === []) {\n $selectedProfile = $candidate;\n $fallbackUsed = true;\n break;\n }\n }\n }\n\n return [\n 'configured' => true,\n 'surface' => $surface,\n 'provider_mode' => $policy->provider_mode,\n 'message_length' => strlen($message),\n 'requested_profile_id' => $policy->default_profile_id,\n 'selected_profile' => $selectedProfile?->only([\n 'profile_id',\n 'display_name',\n 'provider_type',\n 'transport',\n 'entitlement_type',\n 'model',\n 'is_enabled',\n ]),\n 'fallback_profile_ids' => $policy->fallback_profile_ids ?? [],\n 'fallback_used' => $fallbackUsed,\n 'reason' => $blockedReasons === [] ? $policy->provider_mode : $blockedReasons[0],\n 'blocked_reasons' => $blockedReasons,\n 'will_call_paid_provider' => $selectedProfile !== null\n && $this->isCloudProfile($selectedProfile)\n && $blockedReasons === [],\n 'estimated_budget_impact' => $selectedProfile !== null && $this->isCloudProfile($selectedProfile)\n ? 'unknown_until_provider_response'\n : 'zero_api_cost',\n ];\n }\n\n public function isCloudProfile(AbbyProviderProfile $profile): bool\n {\n return $profile->entitlement_type !== 'local' && $profile->transport !== 'ollama_chat';\n }\n\n /**\n * @return array\n */\n public function profilePolicyReferences(string $profileId): array\n {\n return AbbySurfacePolicy::query()\n ->orderBy('surface')\n ->get()\n ->flatMap(function (AbbySurfacePolicy $policy) use ($profileId): array {\n $references = [];\n\n if ($policy->default_profile_id === $profileId) {\n $references[] = [\n 'surface' => $policy->surface,\n 'role' => 'default_profile_id',\n ];\n }\n\n foreach (($policy->fallback_profile_ids ?? []) as $index => $fallbackProfileId) {\n if ((string) $fallbackProfileId === $profileId) {\n $references[] = [\n 'surface' => $policy->surface,\n 'role' => 'fallback_profile_ids',\n 'index' => $index,\n ];\n }\n }\n\n return $references;\n })\n ->values()\n ->all();\n }\n\n public function profileIsReferenced(string $profileId): bool\n {\n return $this->profilePolicyReferences($profileId) !== [];\n }\n\n private function tablesExist(): bool\n {\n return Schema::hasTable('abby_provider_profiles') && Schema::hasTable('abby_surface_policies');\n }\n\n /**\n * @return array\n */\n private function providerSettingsForProfile(AbbyProviderProfile $profile): array\n {\n $providerType = $profile->provider_setting_type ?: $profile->provider_type;\n $provider = AiProviderSetting::where('provider_type', $providerType)\n ->where('is_enabled', true)\n ->first();\n\n return $provider?->settings ?? [];\n }\n\n /**\n * @param array $values\n * @return array\n */\n private function onlyNonEmpty(array $values): array\n {\n return array_filter(\n $values,\n fn ($value): bool => $value !== null && $value !== '',\n );\n }\n\n /**\n * @param array $errors\n *\n * @throws ValidationException\n */\n public function throwIfErrors(array $errors, string $key = 'policy'): void\n {\n if ($errors !== []) {\n throw ValidationException::withMessages([$key => $errors]);\n }\n }\n}\n"}}}}