*/ private const ALLOWED_PROFILES = ['qwen', 'openai']; private const MIN_TIMEOUT = 1; private const MAX_TIMEOUT = 300; /** * @param array $inputs * @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string} */ public static function chat(string $profile, array $inputs, string $query, string $user): array { $config = config('prescription_ai') ?: []; if (empty($config['enable'])) { return self::error('CONFIG_DISABLED', 'AI 报告功能未启用'); } $modelConfig = self::resolveProfileConfig($config, $profile); if ($modelConfig === null) { return self::error('INVALID_PROFILE', '不支持的 AI 模型'); } $baseUrl = trim((string) ($config['base_url'] ?? '')); $rawApiKey = (string) ($modelConfig['api_key'] ?? ''); $apiKey = trim($rawApiKey); if ($baseUrl === '' || $apiKey === '') { return self::error('CONFIG_MISSING', '该模型服务尚未完整配置'); } if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) { return self::error('CONFIG_INVALID', 'AI 服务配置无效'); } $timeout = (int) ($config['timeout'] ?? 0); if (!self::isValidTimeout($timeout)) { return self::error('CONFIG_INVALID', 'AI 服务超时配置无效'); } if (!function_exists('curl_init')) { return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展'); } $model = trim((string) ($modelConfig['name'] ?? '')); if ($model === '') { return self::error('CONFIG_INVALID', 'AI 模型配置无效'); } $requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user); $startedAt = microtime(true); $lastResponse = null; foreach ($requestSpecs as $index => $requestSpec) { $elapsedSeconds = (int) floor(microtime(true) - $startedAt); $remainingTimeout = $timeout - $elapsedSeconds; if ($remainingTimeout < self::MIN_TIMEOUT) { return self::error( 'UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt) ); } $response = self::sendRequest( $requestSpec['url'], $requestSpec['payload'], $apiKey, $remainingTimeout ); $lastResponse = $response; // /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议, // 避免因业务参数错误而重复提交同一份临床数据。 $hasFallback = isset($requestSpecs[$index + 1]); if ($hasFallback && in_array($response['http_code'], [404, 405], true)) { continue; } return self::formatResponse($response, $startedAt); } return self::formatResponse($lastResponse ?? [ 'body' => '', 'errno' => 0, 'http_code' => 0, ], $startedAt); } /** * @param array $config * @return array|null */ private static function resolveProfileConfig(array $config, string $profile): ?array { if (!in_array($profile, self::ALLOWED_PROFILES, true)) { return null; } $modelConfig = $config['models'][$profile] ?? null; return is_array($modelConfig) ? $modelConfig : null; } /** * @param array $inputs * @return array}> */ private static function buildRequestSpecs( string $baseUrl, string $model, array $inputs, string $query, string $user ): array { $baseUrl = rtrim($baseUrl, '/'); $path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? '')); $difySpec = [ 'protocol' => 'dify', 'url' => self::buildEndpoint($baseUrl, 'chat-messages'), 'payload' => [ 'inputs' => $inputs, 'query' => $query, 'response_mode' => 'blocking', 'user' => $user, ], ]; $openAiSpec = [ 'protocol' => 'openai', 'url' => self::buildEndpoint($baseUrl, 'chat/completions'), 'payload' => [ 'model' => $model, 'messages' => [ ['role' => 'user', 'content' => $query], ], ], ]; if (str_ends_with($path, '/chat-messages')) { return [$difySpec]; } if (str_ends_with($path, '/chat/completions')) { return [$openAiSpec]; } // 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。 return [$difySpec, $openAiSpec]; } private static function buildEndpoint(string $baseUrl, string $endpoint): string { $baseUrl = rtrim($baseUrl, '/'); $path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? '')); if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) { return $baseUrl; } if (str_ends_with($path, '/v1')) { return $baseUrl . '/' . $endpoint; } return $baseUrl . '/v1/' . $endpoint; } private static function isValidBaseUrl(string $baseUrl): bool { if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) { return false; } $parts = parse_url($baseUrl); if (!is_array($parts)) { return false; } $scheme = strtolower((string) ($parts['scheme'] ?? '')); return in_array($scheme, ['http', 'https'], true) && trim((string) ($parts['host'] ?? '')) !== '' && !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment']); } private static function isValidTimeout(int $timeout): bool { return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT; } /** * @param array $payload * @return array{body:string,errno:int,http_code:int} */ private static function sendRequest( string $url, array $payload, string $apiKey, int $timeout ): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { return ['body' => '', 'errno' => -1, 'http_code' => 0]; } $ch = curl_init(); if ($ch === false) { return ['body' => '', 'errno' => -2, 'http_code' => 0]; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))), CURLOPT_TIMEOUT => $timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $apiKey, ], ]); $responseBody = curl_exec($ch); $errno = curl_errno($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [ 'body' => is_string($responseBody) ? $responseBody : '', 'errno' => $errno, 'http_code' => $httpCode, ]; } /** * @param array{body:string,errno:int,http_code:int} $response * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} */ private static function formatResponse(array $response, float $startedAt): array { $latencyMs = self::elapsedMilliseconds($startedAt); $errno = $response['errno']; $httpCode = $response['http_code']; if ($errno !== 0) { if ($errno === CURLE_OPERATION_TIMEDOUT) { return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs); } if ($errno === -1) { return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs); } if ($errno === -2) { return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs); } return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs); } $decoded = json_decode($response['body'], true); if ($httpCode === 401 || $httpCode === 403) { return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); } if ($httpCode === 429 || $httpCode >= 500) { return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); } if ($httpCode >= 400 || $httpCode < 200) { return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); } if (!is_array($decoded)) { return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs); } $answer = self::extractContent($decoded); if ($answer === '') { return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs); } return [ 'ok' => true, 'content' => $answer, 'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''), 'latency_ms' => $latencyMs, ]; } /** @param array $decoded */ private static function extractContent(array $decoded): string { $content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? ''; if (is_string($content)) { return trim($content); } if (!is_array($content)) { return ''; } $parts = []; foreach ($content as $part) { if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) { $parts[] = $part['text']; } } return trim(implode('', $parts)); } private static function elapsedMilliseconds(float $startedAt): int { return (int) round((microtime(true) - $startedAt) * 1000); } /** * @return array{ok:false,error_code:string,error:string,latency_ms:int} */ private static function error(string $code, string $message, int $latencyMs = 0): array { return [ 'ok' => false, 'error_code' => $code, 'error' => $message, 'latency_ms' => $latencyMs, ]; } }