*/ private const ALLOWED_PROFILES = ['qwen', 'openai']; private const MIN_TIMEOUT = 1; private const MAX_TIMEOUT = 300; private const DEFAULT_MAX_FILES = 3; private const CANCEL_METADATA_SECONDS = 2.0; private const CANCEL_DRAIN_SECONDS = 2.5; /** * 上游明确以“这批附件我处理不了”拒绝整次请求时使用的状态码。 * 命中后会去掉附件重试一次,避免一张舌象图让整份病历分析失败。 */ private const FILE_REJECTION_CODES = [400, 413, 415, 422]; private const MAX_ERROR_BODY_BYTES = 16384; private const UPSTREAM_REASONS = [ 'authentication', 'quota', 'unavailable', 'cancelled', 'inputs_invalid', 'response_mode_unsupported', 'file_type', 'file_size', 'file_count', 'file_access', ]; /** * @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 $files = [], array $options = [] ): array { // Cancellable callers collect a real upstream stream internally. Dify's stop // API cannot stop blocking mode. Legacy blocking callers are unchanged. // Only the server's pre-delta compatibility retry opts into real blocking; // its abort closes transport and deliberately leaves remote stop unconfirmed. if (is_callable($options['should_abort'] ?? null) && empty($options['blocking_compatibility'])) { return self::streamChat($profile, $inputs, $query, $user, static fn (string $delta): bool => true, $options['should_abort'], $files, $options); } $config = config('prescription_ai') ?: []; $shouldAbort = is_callable($options['should_abort'] ?? null) ? $options['should_abort'] : null; 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 服务配置无效'); } // A caller may raise the single-request budget for staged background analysis. It stays // bounded by MAX_TIMEOUT; interactive callers keep the configured default. $timeout = (int) ($options['timeout'] ?? $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 模型配置无效'); } // Evidence extraction is a server-selected intermediate operation, not a // clinical synthesis. Do not inject book facts into source summaries. $evidenceOnly = ($options['knowledge_mode'] ?? '') === 'evidence'; $ancientBookSources = $evidenceOnly ? [] : ClinicalKnowledgeReference::ancientBookSources($query); try { if (!$evidenceOnly) { $query = ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($query, $ancientBookSources); } } catch (\RuntimeException $error) { return self::error('SKILL_UNAVAILABLE', '临床 AI 参考资料不可用'); } if ($shouldAbort !== null && $shouldAbort()) return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); $requiredFiles = !empty($options['required_files']); if ($requiredFiles) { $checked = self::requiredFiles($files); if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error'], $files); $files = $checked['files']; } $strictFiles = !empty($options['strict_files']) || $requiredFiles; // A clinical request declares every file mandatory. Local legacy defaults are // not provider capabilities: submit them all, and surface any real limit. $normalized = self::normalizeFiles($files, $requiredFiles ? count($files) : self::maxFiles($config, $modelConfig), !$strictFiles); if ($strictFiles && !self::strictFilesComplete($files, $normalized)) { return self::error('STRICT_FILES_INVALID_OR_LIMIT', '附件无效或超出单批上限,须分批处理'); } $startedAt = microtime(true); $formatted = null; $uploadCache = []; $attemptPlan = $strictFiles ? [['files' => $normalized['kept'], 'omitted' => []]] : self::buildAttemptPlan($normalized['kept'], $normalized['dropped']); foreach ($attemptPlan as $attempt) { $fileRejected = false; $inputRejected = false; foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) { if ($inputIndex > 0 && !$inputRejected) { break; } $requestSpecs = self::buildRequestSpecs( $baseUrl, $model, $attemptInputs, $query, $user, false, $attempt['files'], $attempt['omitted'] ); $lastResponse = null; $lastSpec = []; $lastTransferOptions = $options; foreach ($requestSpecs as $index => $requestSpec) { if ($shouldAbort !== null && $shouldAbort()) return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); // The legacy compatible path cannot transmit documents. Strict callers must // receive an explicit gap, never a successful text-only fallback. if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], self::availableSourceFiles($attempt['files'], $uploadCache))) { if ($requiredFiles) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); $remainingTimeout = $timeout - $elapsedSeconds; if ($remainingTimeout < self::MIN_TIMEOUT) { return self::error( 'UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt) ); } $staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey, $remainingTimeout, $shouldAbort, $options, $config, $uploadCache, $query); if (empty($staged['ok'])) return $staged; $requestSpec = $staged['request_spec']; $lastTransferOptions = $staged['options']; $remainingTimeout = $timeout - (int) floor(microtime(true) - $startedAt); if ($remainingTimeout < self::MIN_TIMEOUT) return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt)); $response = self::sendRequest( $requestSpec['url'], $requestSpec['payload'], $apiKey, $remainingTimeout, $lastTransferOptions + ['attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] ); if (!empty($response['client_aborted']) || ($shouldAbort !== null && $shouldAbort())) { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($startedAt)); } $lastResponse = $response; $lastSpec = $requestSpec; // A corrected inputs attempt or protocol response owns its own diagnosis. $fileRejected = self::isFileRejection($response, $attempt['files'], $requiredFiles); $inputRejected = self::isInputRejection( $response, $requestSpec, $attemptInputs ); // /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议, // 避免因业务参数错误而重复提交同一份临床数据。 $hasFallback = isset($requestSpecs[$index + 1]); if ( $hasFallback && !$inputRejected && self::shouldTryNextProtocol($response, false) ) { continue; } break; } $lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0]; $formatted = self::formatResponse($lastResponse, $startedAt); self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted); if (!empty($formatted['ok'])) { $formatted['ancient_book_sources'] = $ancientBookSources; if ($strictFiles) { $formatted['transmitted_file_count'] = count(self::availableSourceFiles($attempt['files'], $uploadCache)); $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); } if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); return $formatted; } // Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整 // 放在 query 中,因此明确的 inputs 校验错误可用空对象重试一次。 if ($inputIndex === 0 && $inputRejected) { continue; } if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求', $attempt['files'], (string) ($lastSpec['protocol'] ?? ''), (int) ($formatted['latency_ms'] ?? 0)); break; } // 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。 if (!$fileRejected) { return $formatted; } } return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt)); } /** * 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。 * * @param array $inputs * @param callable(string):mixed $onDelta * @param callable():bool|null $shouldAbort * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} */ public static function streamChat( string $profile, array $inputs, string $query, string $user, callable $onDelta, ?callable $shouldAbort = null, array $files = [], array $options = [] ): 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) ($options['timeout'] ?? $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 模型配置无效'); } $evidenceOnly = ($options['knowledge_mode'] ?? '') === 'evidence'; $ancientBookSources = $evidenceOnly ? [] : ClinicalKnowledgeReference::ancientBookSources($query); try { if (!$evidenceOnly) { $query = ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($query, $ancientBookSources); } } catch (\RuntimeException $error) { return self::error('SKILL_UNAVAILABLE', '临床 AI 参考资料不可用'); } if ($shouldAbort !== null && $shouldAbort()) return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); $requiredFiles = !empty($options['required_files']); if ($requiredFiles) { $checked = self::requiredFiles($files); if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error'], $files); $files = $checked['files']; } $strictFiles = !empty($options['strict_files']) || $requiredFiles; $normalized = self::normalizeFiles($files, $requiredFiles ? count($files) : self::maxFiles($config, $modelConfig), !$strictFiles); if ($strictFiles && !self::strictFilesComplete($files, $normalized)) { return self::error('STRICT_FILES_INVALID_OR_LIMIT', '附件无效或超出单批上限,须分批处理'); } $startedAt = microtime(true); $formatted = null; $uploadCache = []; $attemptPlan = $strictFiles ? [['files' => $normalized['kept'], 'omitted' => []]] : self::buildAttemptPlan($normalized['kept'], $normalized['dropped']); foreach ($attemptPlan as $attempt) { $fileRejected = false; $inputRejected = false; foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) { if ($inputIndex > 0 && !$inputRejected) { break; } $requestSpecs = self::buildRequestSpecs( $baseUrl, $model, $attemptInputs, $query, $user, true, $attempt['files'], $attempt['omitted'] ); $lastResponse = null; $lastSpec = []; $lastTransferOptions = $options; foreach ($requestSpecs as $index => $requestSpec) { if ($shouldAbort !== null && $shouldAbort()) { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); } if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], self::availableSourceFiles($attempt['files'], $uploadCache))) { if ($requiredFiles) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); $remainingTimeout = $timeout - $elapsedSeconds; if ($remainingTimeout < self::MIN_TIMEOUT) { return self::error( 'UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt) ); } $staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey, $remainingTimeout, $shouldAbort, $options, $config, $uploadCache, $query); if (empty($staged['ok'])) return $staged; $requestSpec = $staged['request_spec']; $lastTransferOptions = $staged['options']; $remainingTimeout = $timeout - (int) floor(microtime(true) - $startedAt); if ($remainingTimeout < self::MIN_TIMEOUT) return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt)); $response = self::sendStreamRequest( $requestSpec['protocol'], $requestSpec['url'], $requestSpec['payload'], $apiKey, $remainingTimeout, $onDelta, $shouldAbort, $lastTransferOptions + ['profile' => $profile, 'user' => $user, 'source_summary' => $options['source_summary'] ?? [], 'attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] ); $lastResponse = $response; $lastSpec = $requestSpec; $fileRejected = self::isFileRejection($response, $attempt['files'], $requiredFiles); $inputRejected = self::isInputRejection( $response, $requestSpec, $attemptInputs ); // 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。 $hasFallback = isset($requestSpecs[$index + 1]); if ( $hasFallback && !$inputRejected && self::shouldTryNextProtocol($response, true) ) { continue; } break; } $lastResponse = $lastResponse ?? self::emptyStreamResponse(0); $formatted = self::formatStreamResponse($lastResponse, $startedAt); if (!empty($lastResponse['client_aborted']) || !empty($lastResponse['callback_error']) || ($shouldAbort !== null && $shouldAbort())) { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($startedAt)); } self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted); if (!empty($formatted['ok'])) { $formatted['ancient_book_sources'] = $ancientBookSources; if ($strictFiles) { $formatted['transmitted_file_count'] = count(self::availableSourceFiles($attempt['files'], $uploadCache)); $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); } if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); return $formatted; } if ( $inputIndex === 0 && $inputRejected && empty($lastResponse['emitted']) ) { continue; } if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求', $attempt['files'], (string) ($lastSpec['protocol'] ?? ''), (int) ($formatted['latency_ms'] ?? 0)); break; } // 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。 if (!empty($lastResponse['emitted']) || !$fileRejected) { return $formatted; } } return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt)); } /** The task id is obtained from streaming metadata; user must match the original request. */ public static function stopTask(string $profile, string $taskId, string $user): bool { if (preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $taskId) !== 1 || $user === '') return false; $config = config('prescription_ai') ?: []; $model = self::resolveProfileConfig($config, $profile); $base = trim((string) ($config['base_url'] ?? '')); $key = (string) ($model['api_key'] ?? ''); if (!self::isValidBaseUrl($base) || trim($key) === '' || strpbrk($key, "\r\n") !== false) return false; $endpoint = self::buildEndpoint(rtrim($base, '/'), 'chat-messages'); $response = self::sendRequest($endpoint . '/' . rawurlencode($taskId) . '/stop', ['user' => $user], trim($key), 1); $body = json_decode((string) ($response['body'] ?? ''), true); return ($response['errno'] ?? -1) === 0 && ($response['http_code'] ?? 0) === 200 && ($body['result'] ?? '') === 'success'; } /** Upload available files once; confirmed missing sources never block the clinical text. */ private static function prepareRequiredUpload(array $spec, array $files, string $user, string $apiKey, int $timeout, ?callable $shouldAbort, array $options, array $config, array &$cache, string $sourceQuery): array { if (empty($options['required_files']) || $files === [] || ($config['required_attachment_transport'] ?? '') !== 'upload') { return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; } $sourceKey = 'sources:' . hash('sha256', json_encode($files)); if (($spec['protocol'] ?? '') !== 'dify') { if (isset($cache[$sourceKey])) { $receipt = $cache[$sourceKey]; $options['attachment_items'] = $receipt['items']; // Rebuild transport-only content from the original clinical query // and available sources; never splice URLs out of clinical text. $available = self::availableSourceFiles($files, $cache); $spec['payload']['messages'] = [['role' => 'user', 'content' => self::buildOpenAiContent( self::withAttachmentManifest($sourceQuery, self::nonImageFiles($available)), $available )]]; $spec = self::addMissingAttachmentNotice($spec, $receipt['items']); } return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; } $options['attachment_transfer'] = 'uploaded_files'; $cacheKey = hash('sha256', $spec['url'] . "\0" . $apiKey . "\0" . $user . "\0" . json_encode($files)); if (!isset($cache[$cacheKey])) { $uploadOptions = is_array($config['required_attachment_upload'] ?? null) ? $config['required_attachment_upload'] : []; if (!array_key_exists('storage_origins', $uploadOptions)) { // These URLs are already authorized clinical sources, not request // parameters. This grants remote-origin permission only; the // uploader still rejects private DNS/IPs, redirects and rebinding. $origins = []; foreach ($files as $file) { $parts = parse_url($file['url']); if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) continue; $origin = strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : ''); $origins[$origin] = true; } $uploadOptions['storage_origins'] = array_keys($origins); } $items = self::attachmentItems($files, [], 'dify'); foreach ($items as &$item) $item['status'] = 'prepared'; unset($item); $uploadOptions['on_upload'] = static function (array $event) use ($options, &$items): void { $index = (int) ($event['index'] ?? 0); if ($index < 1 || !isset($items[$index - 1])) return; $status = $event['status'] ?? ''; if (!in_array($status, ['uploading', 'uploaded', 'failed', 'skipped'], true)) return; $items[$index - 1]['status'] = $status; if ($status === 'skipped' && ($event['reason'] ?? '') === 'not_found') { $items[$index - 1]['reason'] = 'not_found'; } $sections = self::requiredSections($options, 'prepared'); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; $summary['required_sections'] = $sections; if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([ 'phase' => 'attachments', 'status' => 'uploading', 'upload_index' => $index, 'upload_total' => count($items), 'items' => $items, 'attachment_count' => count($items), 'required_sections' => $sections, 'source_summary' => $summary, 'source_form' => $options['source_form'] ?? 'full_context', 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), 'attachment_transfer' => 'uploaded_files', 'skipped_attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] === 'skipped')), ]); }; $started = microtime(true); $uploaded = DifyRequiredAttachmentUpload::prepare($spec['url'], $apiKey, $user, $files, $timeout, $shouldAbort, $uploadOptions); $options['attachment_items'] = $items; if (empty($uploaded['ok'])) { if (($uploaded['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED') { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($started)); } return self::requiredFilesFailure($options, (string) ($uploaded['error_code'] ?? 'REQUIRED_FILES_UPLOAD_FAILED'), (string) ($uploaded['error'] ?? '必需附件上传失败,本次未提交分析'), $files, 'dify', self::elapsedMilliseconds($started)); } $receipt = self::uploadedAttachmentReceipt($uploaded, $files); if ($receipt === null) { return self::requiredFilesFailure($options, 'REQUIRED_FILES_UPLOAD_FAILED', '必需附件上传不完整,本次未提交分析', $files); } $cache[$cacheKey] = $receipt; $cache[$sourceKey] = $receipt; } $options['attachment_items'] = $cache[$cacheKey]['items']; $spec['payload']['files'] = $cache[$cacheKey]['files']; $spec = self::addMissingAttachmentNotice($spec, $cache[$cacheKey]['items']); return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; } /** Require an explicit, disjoint result for every original source; no silent omission. */ private static function uploadedAttachmentReceipt(array $uploaded, array $files): ?array { if (!is_array($uploaded['files'] ?? null) || !array_is_list($uploaded['files'])) return null; $skipped = $uploaded['skipped'] ?? []; $indices = $uploaded['uploaded_indices'] ?? (count($uploaded['files']) === count($files) ? ($files === [] ? [] : range(1, count($files))) : null); if (!is_array($skipped) || !array_is_list($skipped) || !is_array($indices) || !array_is_list($indices) || count($indices) !== count($uploaded['files'])) return null; $items = self::attachmentItems($files, [], 'dify'); $seen = []; foreach ($skipped as $skip) { $index = $skip['index'] ?? null; if (!is_int($index) || $index < 1 || $index > count($files) || isset($seen[$index]) || ($skip['reason'] ?? '') !== 'not_found' || ($skip['kind'] ?? '') !== $files[$index - 1]['type']) return null; $seen[$index] = true; $items[$index - 1]['status'] = 'skipped'; $items[$index - 1]['reason'] = 'not_found'; } $previous = 0; foreach ($indices as $position => $index) { $wire = $uploaded['files'][$position]; if (!is_int($index) || $index <= $previous || $index > count($files) || isset($seen[$index]) || ($wire['type'] ?? '') !== $files[$index - 1]['type'] || ($wire['transfer_method'] ?? '') !== 'local_file' || !is_string($wire['upload_file_id'] ?? null) || trim($wire['upload_file_id']) === '') return null; $seen[$index] = true; $previous = $index; $items[$index - 1]['status'] = 'uploaded'; } if (count($seen) !== count($files)) return null; return ['files' => $uploaded['files'], 'items' => $items, 'indices' => $indices, 'skipped' => $skipped]; } private static function availableSourceFiles(array $files, array $cache): array { $receipt = $cache['sources:' . hash('sha256', json_encode($files))] ?? null; return is_array($receipt) ? array_map(static fn (int $index): array => $files[$index - 1], $receipt['indices']) : $files; } /** A trusted transport notice tells the model exactly which references were unavailable. */ private static function addMissingAttachmentNotice(array $spec, array $items): array { $missing = array_values(array_filter($items, static fn (array $item): bool => ($item['status'] ?? '') === 'skipped')); if ($missing === []) return $spec; $notice = "\n\n【附件可用性说明】\n" . implode(';', array_map( static fn (array $item): string => $item['label'] . '源文件不存在,已跳过,未提供给模型', $missing)) . '。请继续依据已提供的病例正文及其余可用附件回答,并明确资料缺口;不得声称已查看这些缺失附件。'; if (isset($spec['payload']['query'])) { $spec['payload']['query'] .= $notice; } else { $spec['payload']['messages'][] = ['role' => 'user', 'content' => $notice]; } return $spec; } /** Mandatory clinical files have no lossy normalization or text-only fallback. */ private static function requiredFiles(array $files): array { $required = []; foreach ($files as $file) { if (!is_array($file) || !is_string($file['type'] ?? null) || !is_string($file['url'] ?? null) || (isset($file['mime_type']) && !is_string($file['mime_type'])) || (isset($file['mime']) && !is_string($file['mime']))) { return ['error_code' => 'REQUIRED_FILES_INVALID', 'error' => '必需资料提交失败:附件资料无效']; } $type = strtolower(trim((string) ($file['type'] ?? ''))); $url = trim((string) ($file['url'] ?? '')); $extension = strtolower(pathinfo((string) (parse_url($url, PHP_URL_PATH) ?? ''), PATHINFO_EXTENSION)); $mime = strtolower(trim((string) ($file['mime_type'] ?? $file['mime'] ?? ''))); // Recorded consultations are represented exclusively by their existing // transcript. Never send a video, even when its declared type is wrong. if ($type === 'video' || str_starts_with($mime, 'video/') || in_array($extension, ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm3u8', 'm4v', 'wmv', 'flv', 'mpeg', 'mpg', '3gp'], true)) continue; if (!in_array($type, ['image', 'document', 'audio'], true) || !self::isValidRemoteFileUrl($url) || !in_array($file['transfer_method'] ?? 'remote_url', ['remote_url'], true)) { return ['error_code' => 'REQUIRED_FILES_INVALID', 'error' => '必需资料提交失败:存在未知类型或无效附件,未生成不完整报告']; } $required[] = ['type' => $type, 'transfer_method' => 'remote_url', 'url' => $url]; } return ['files' => $required]; } private static function requiredSections(array $options, string $status): array { $sections = $options['required_sections'] ?? []; if (!is_array($sections) || $sections === []) $sections = $options['source_summary']['required_sections'] ?? []; if (!is_array($sections)) return []; $result = []; foreach ($sections as $section) { if (!is_array($section) || !is_string($section['key'] ?? null)) continue; $result[] = ['key' => $section['key'], 'label' => (string) ($section['label'] ?? ''), 'count' => max(0, (int) ($section['count'] ?? 0)), 'availability' => ($section['availability'] ?? '') === 'empty' ? 'empty' : 'present', 'status' => $status]; } return $result; } private static function requiredFilesFailure( array $options, string $code, string $message, array $files = [], string $protocol = 'dify', int $latencyMs = 0 ): array { $sections = self::requiredSections($options, 'failed'); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; $summary['required_sections'] = $sections; // Even a preflight-invalid file gets a safe label; never expose its URL/name. $labels = array_map(static function ($file): array { $kind = is_array($file) && is_string($file['type'] ?? null) ? strtolower($file['type']) : 'custom'; return ['type' => in_array($kind, ['image', 'document', 'audio', 'video', 'custom'], true) ? $kind : 'custom']; }, $files); $items = self::transferManifestItems($options, $labels, $protocol, 'failed'); $manifest = ['required_sections' => $sections, 'source_summary' => $summary, 'items' => $items, 'attachment_count' => count($items), 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true] + self::attachmentOutcomeCounts($items); if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([ 'phase' => 'attachments', 'status' => 'failed', 'message' => $message, ] + $manifest); return self::error($code, $message, $latencyMs) + $manifest; } private static function finalTransferManifest(array $options, array $files, string $protocol): array { $sections = self::requiredSections($options, 'submitted'); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; $summary['required_sections'] = $sections; $items = self::transferManifestItems($options, $files, $protocol, 'submitted'); return ['required_sections' => $sections, 'source_summary' => $summary, 'items' => $items, 'attachment_count' => count($files), 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true] + self::attachmentOutcomeCounts($items); } private static function transferManifestItems(array $options, array $files, string $protocol, string $status): array { $items = is_array($options['attachment_items'] ?? null) ? $options['attachment_items'] : self::attachmentItems($files, [], $protocol); foreach ($items as &$item) { if (!in_array($item['status'] ?? '', ['skipped', 'omitted'], true)) $item['status'] = $status; } unset($item); return $items; } private static function attachmentOutcomeCounts(array $items): array { return ['submitted_attachment_count' => count(array_filter($items, static fn (array $item): bool => ($item['status'] ?? '') === 'submitted')), 'skipped_attachment_count' => count(array_filter($items, static fn (array $item): bool => ($item['status'] ?? '') === 'skipped'))]; } /** Safe labels only; submitted means request references sent, never remote file reading. */ private static function attachmentItems(array $files, array $omitted, string $protocol): array { $labels = ['image' => '图片附件', 'document' => '文档附件', 'audio' => '音频附件', 'video' => '视频附件', 'custom' => '其他附件']; $items = []; $counts = []; foreach (array_merge($files, $omitted) as $index => $file) { $kind = $file['type'] ?? 'custom'; $counts[$kind] = ($counts[$kind] ?? 0) + 1; $transmitted = $index < count($files) && ($protocol === 'dify' || $kind === 'image'); $items[] = ['label' => ($labels[$kind] ?? '附件') . $counts[$kind], 'kind' => $kind, 'status' => $transmitted ? 'submitting' : 'omitted']; } return $items; } /** * @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 * @param array> $files 随请求送达的附件 * @param array> $omitted 超出上游数量上限、只能写进清单的附件 * @return array}> */ private static function buildRequestSpecs( string $baseUrl, string $model, array $inputs, string $query, string $user, bool $streaming = false, array $files = [], array $omitted = [] ): array { $baseUrl = rtrim($baseUrl, '/'); $path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? '')); // Dify 能承载全部附件类型,只需补上被数量上限截断的清单。 // inputs 必须是 JSON 对象:空数组会被 json_encode 成 [],Dify 直接 // 以 invalid_param 拒绝整单,因此这里强制对象语义。 $difySpec = [ 'protocol' => 'dify', 'url' => self::buildEndpoint($baseUrl, 'chat-messages'), 'payload' => [ 'inputs' => (object) $inputs, 'query' => self::withAttachmentManifest($query, $omitted), 'response_mode' => $streaming ? 'streaming' : 'blocking', 'user' => $user, // The workstation does not use Dify conversation titles. Avoid a // separate background LLM call that may outlive the answer's stop. 'auto_generate_name' => false, ], ]; if ($files !== []) { $difySpec['payload']['files'] = $files; } // Chat Completions 只能内联图片,非图片附件与被截断的附件一并进清单。 $openAiContent = self::buildOpenAiContent( self::withAttachmentManifest( $query, array_merge(self::nonImageFiles($files), $omitted) ), $files ); $openAiSpec = [ 'protocol' => 'openai', 'url' => self::buildEndpoint($baseUrl, 'chat/completions'), 'payload' => [ 'model' => $model, 'messages' => [ ['role' => 'user', 'content' => $openAiContent], ], 'stream' => $streaming, ], ]; if (!$streaming) { unset($openAiSpec['payload']['stream']); } if (str_ends_with($path, '/chat-messages')) { return [$difySpec]; } if (str_ends_with($path, '/chat/completions')) { return [$openAiSpec]; } // 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。 // 早期实现只在“无附件或全是图片”时提供回退,患者带检查报告/录像附件时 // Dify 路径 404 会直接变成“模型未能处理本次请求”,因此这里始终保留回退, // 非图片附件改为在正文中以清单形式随请求送达,绝不静默丢弃。 return [$difySpec, $openAiSpec]; } /** * 构造 OpenAI-compatible 正文。图片走多模态 image_url;非图片附件已由调用方 * 写进 $query 末尾的清单,这里只负责内联图片。 * * @param array> $files * @return string|array> */ private static function buildOpenAiContent(string $query, array $files) { $content = [['type' => 'text', 'text' => $query]]; foreach ($files as $file) { $url = (string) ($file['url'] ?? ''); if ($url === '' || ($file['type'] ?? '') !== 'image') { continue; } $content[] = [ 'type' => 'image_url', 'image_url' => ['url' => $url], ]; } return count($content) === 1 ? $query : $content; } /** * @param array> $files * @return array> */ private static function nonImageFiles(array $files): array { return array_values(array_filter( $files, static fn (array $file): bool => ($file['type'] ?? '') !== 'image' )); } private static function strictFilesComplete(array $requested, array $normalized): bool { return count($normalized['kept']) === count($requested) && $normalized['dropped'] === []; } private static function strictProtocolSupportsFiles(string $protocol, array $files): bool { return $protocol === 'dify' || ($protocol === 'openai' && self::nonImageFiles($files) === []); } /** * 清洗附件,并按上游应用允许的数量截断。 * * Dify 用 file_upload.number_limits 校验单次请求的附件总数,超出即返回 * 400 invalid_param 拒绝整单。患者纵向资料的附件数量不可控(舌象、报告、 * 录像可能几十份),因此这里必须主动截断;被截断的附件不会被悄悄丢弃, * 而是以清单形式随提示词送达,让模型知道存在哪些它读不到的资料。 * 保持调用方给定的顺序,由调用方决定哪些附件最值得送上去。 * 严格批次关闭 URL 去重,保留同一地址对应的每个逻辑附件及其清单位置。 * * @param array $files * @return array{ * kept:array, * dropped:array * } */ private static function normalizeFiles(array $files, int $maxFiles, bool $deduplicate = true): array { $maxFiles = max(0, $maxFiles); $kept = []; $dropped = []; $seen = []; foreach ($files as $file) { if (!is_array($file)) { continue; } $type = strtolower(trim((string) ($file['type'] ?? ''))); $url = trim((string) ($file['url'] ?? '')); if (!in_array($type, ['image', 'document', 'audio', 'video', 'custom'], true) || !self::isValidRemoteFileUrl($url) || ($deduplicate && isset($seen[$url]))) { continue; } $seen[$url] = true; $normalized = [ 'type' => $type, 'transfer_method' => 'remote_url', 'url' => $url, ]; if (count($kept) >= $maxFiles) { $dropped[] = $normalized; continue; } $kept[] = $normalized; } return ['kept' => $kept, 'dropped' => $dropped]; } /** @param array $config */ /** Each application declares its own file_upload.number_limits; the global value is the fallback. */ private static function maxFiles(array $config, array $modelConfig = []): int { $configured = array_key_exists('max_files', $modelConfig) ? (int) $modelConfig['max_files'] : (int) ($config['max_files'] ?? self::DEFAULT_MAX_FILES); return $configured >= 0 ? $configured : self::DEFAULT_MAX_FILES; } /** * 排出两轮尝试:先带附件,附件被上游整体拒绝时再只发文本。 * 第二轮把全部附件写进清单,保证降级后模型仍知道资料缺口。 * * @param array> $files * @param array> $dropped * @return array>,omitted:array>}> */ private static function buildAttemptPlan(array $files, array $dropped): array { $attempts = [['files' => $files, 'omitted' => $dropped]]; if ($files !== []) { $attempts[] = ['files' => [], 'omitted' => array_merge($files, $dropped)]; } return $attempts; } /** * Dify 应用输入变量由发布时的表单定义决定。先保留结构化输入;若上游明确 * 拒绝输入,再使用空对象兼容旧应用。病例正文始终在 query 中,不会丢失。 * * @param array $inputs * @return array> */ private static function buildInputAttemptPlan(array $inputs): array { return $inputs === [] ? [[]] : [$inputs, []]; } /** * @param array $response * @param array $requestSpec * @param array $inputs */ private static function isInputRejection(array $response, array $requestSpec, array $inputs): bool { if ( $inputs === [] || ($requestSpec['protocol'] ?? '') !== 'dify' || (int) ($response['errno'] ?? 0) !== 0 || !empty($response['emitted']) ) { return false; } $httpCode = (int) ($response['http_code'] ?? 0); return (in_array($httpCode, [400, 413, 422], true) || ($httpCode >= 200 && $httpCode < 300 && !empty($response['upstream_error']))) && self::responseUpstreamReason($response) === 'inputs_invalid'; } /** @param array $response */ private static function responseUpstreamCode(array $response): string { $upstreamCode = self::cleanUpstreamCode($response['upstream_code'] ?? ''); if ($upstreamCode !== '' || !isset($response['body'])) { return $upstreamCode; } return self::errorBodyMetadata((string) $response['body'])['upstream_code']; } /** Only short codes and fixed classifications may leave the parser. */ private static function errorBodyMetadata(string $body): array { if (strlen($body) > self::MAX_ERROR_BODY_BYTES) return ['upstream_code' => '', 'upstream_reason' => '']; $decoded = json_decode($body, true); return is_array($decoded) ? self::upstreamErrorMetadata($decoded) : ['upstream_code' => '', 'upstream_reason' => '']; } private static function responseUpstreamReason(array $response): string { $reason = $response['upstream_reason'] ?? ''; if (is_string($reason) && in_array($reason, self::UPSTREAM_REASONS, true)) return $reason; if (isset($response['body'])) { $metadata = self::errorBodyMetadata((string) $response['body']); if ($metadata['upstream_reason'] !== '') return $metadata['upstream_reason']; } return self::upstreamErrorMetadata(['code' => self::responseUpstreamCode($response)])['upstream_reason']; } /** * Error messages are inspected only in memory and immediately reduced to an * enum. They can contain signed attachment URLs or clinical text: never return * or log them. Generic invalid_param/413/422 alone prove no specific cause. */ private static function upstreamErrorMetadata(array $decoded): array { $nested = is_array($decoded['error'] ?? null) ? $decoded['error'] : []; $code = self::cleanUpstreamCode($decoded['code'] ?? $nested['code'] ?? $nested['type'] ?? ''); $normalizedCode = strtolower($code); $message = $decoded['message'] ?? $nested['message'] ?? ''; $message = is_string($message) ? substr($message, 0, self::MAX_ERROR_BODY_BYTES) : ''; $reason = ''; $codeGroups = [ 'authentication' => ['unauthorized', 'forbidden', 'invalid_api_key', 'invalid_credentials', 'authentication_error', 'authorization_error', 'provider_not_initialize', 'provider_token_not_init'], 'quota' => ['provider_quota_exceeded', 'quota_exceeded', 'insufficient_quota', 'rate_limit_exceeded', 'rate_limit_error', 'too_many_requests'], 'unavailable' => ['provider_unavailable', 'service_unavailable', 'internal_server_error', 'server_error', 'model_unavailable'], 'cancelled' => ['cancelled', 'canceled', 'task_cancelled', 'task_canceled', 'generation_cancelled'], 'response_mode_unsupported' => ['unsupported_response_mode', 'response_mode_not_supported'], 'file_type' => ['unsupported_file_type', 'file_type_not_supported', 'invalid_file_type', 'unsupported_image_type', 'invalid_image_format', 'invalid_image'], 'file_size' => ['file_too_large', 'image_too_large', 'file_size_exceeded', 'file_size_limit_exceeded', 'max_file_size_exceeded'], 'file_count' => ['too_many_files', 'file_count_exceeded', 'file_count_limit_exceeded', 'files_limit_exceeded', 'max_files_exceeded'], 'file_access' => ['file_not_found', 'file_not_accessible', 'file_access_error', 'file_download_error', 'file_download_failed', 'remote_file_download_failed', 'file_fetch_error', 'invalid_file_url', 'invalid_image_url', 'image_download_failed', 'image_fetch_failed'], 'inputs_invalid' => ['invalid_inputs', 'invalid_input_variable', 'input_validation_error', 'input_variable_error'], ]; foreach ($codeGroups as $candidate => $codes) { if (in_array($normalizedCode, $codes, true)) { $reason = $candidate; break; } } // Exact provider codes take priority over words from an error message. if ($reason === '' && $message !== '') { $patterns = [ 'authentication' => '/\b(?:api[ _-]?key|credentials?|authentication|authorization)\b.{0,80}\b(?:invalid|missing|failed|unauthorized)\b|\b(?:invalid|missing)\b.{0,40}\b(?:api[ _-]?key|credentials?)\b/i', 'quota' => '/\b(?:quota|rate[ _-]?limit)\b.{0,60}\b(?:exceeded|exhausted|reached|insufficient)\b|\binsufficient\s+(?:quota|credits?|balance)\b/i', 'response_mode_unsupported' => '/\bresponse_mode\b.{0,100}\b(?:invalid|unsupported|not supported|blocking|streaming)\b|\bstreaming\b.{0,50}\b(?:not supported|unsupported|disabled)\b/i', 'file_count' => '/\b(?:number|count)\s+of\s+(?:uploaded\s+)?(?:files|images|attachments)\b.{0,100}\b(?:limit|exceed|maximum)\b|\btoo many\s+(?:files|images|attachments)\b|\b(?:file|image|attachment)[ _-]?(?:count|number|limit)\b.{0,80}\b(?:exceed|maximum|limit)\b/i', 'file_size' => '/\b(?:file|image|attachment)(?:\s+size)?\b.{0,80}\b(?:too large|size limit|maximum size|exceeds? (?:the )?(?:size|maximum))\b/i', 'file_type' => '/\b(?:file|image|attachment)\s+(?:type|format|extension)\b.{0,80}\b(?:unsupported|not supported|not allowed|invalid)\b|\b(?:unsupported|invalid|not supported)\s+(?:file|image|attachment)(?:\s+(?:type|format|extension))?\b/i', 'file_access' => '/\b(?:failed|unable|cannot|could not)\s+to\s+(?:download|fetch|read|access)\b.{0,160}\b(?:file|image|attachment)\b|\b(?:file|image|attachment)(?:\s+url)?\b.{0,100}\b(?:download failed|fetch failed|not accessible|unreachable|not found|access denied)\b/i', 'inputs_invalid' => '/\b(?:inputs|input (?:variable|field|form)s?)\b.{0,120}\b(?:unknown|undeclared|unexpected|invalid|not (?:defined|declared|allowed)|too long|exceeds?|less than|maximum length)\b|\b(?:unknown|undeclared|unexpected|invalid)\s+(?:inputs|input (?:variable|field)s?)\b/i', ]; foreach ($patterns as $candidate => $pattern) { if (preg_match($pattern, $message) === 1) { $reason = $candidate; break; } } } return ['upstream_code' => $code, 'upstream_reason' => $reason]; } /** * @param array> $files */ private static function shouldRetryWithoutFiles(int $httpCode, array $files): bool { return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true); } /** * 判断一次上游响应是否属于“这批附件我处理不了”。 * * 除了 4xx 状态码,Dify 拉不到附件时会在 200 的 SSE 流里发 event:error, * 这两种形态都必须触发去掉附件的降级重试。 * * @param array $response * @param array> $files */ private static function isFileRejection(array $response, array $files, bool $requireExplicit = false): bool { if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) { return false; } $httpCode = (int) ($response['http_code'] ?? 0); if (!empty($response['client_aborted']) || !empty($response['callback_error']) || in_array($httpCode, [401, 403, 429], true) || $httpCode >= 500) return false; $reason = self::responseUpstreamReason($response); if (in_array($reason, ['authentication', 'quota', 'unavailable', 'cancelled', 'inputs_invalid', 'response_mode_unsupported'], true)) return false; if ($requireExplicit) { return (in_array($httpCode, self::FILE_REJECTION_CODES, true) || ($httpCode >= 200 && $httpCode < 300 && !empty($response['upstream_error']))) && in_array($reason, ['file_type', 'file_size', 'file_count', 'file_access'], true); } // Preserve the legacy optional-files fallback contract. Mandatory files // instead require affirmative attachment evidence and are never removed. if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) { return true; } return !empty($response['upstream_error']); } /** * 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到, * 才不会把“没看到”当成“没有”。 * * @param array> $omitted */ private static function withAttachmentManifest(string $query, array $omitted): string { $lines = []; foreach ($omitted as $file) { $url = (string) ($file['url'] ?? ''); if ($url === '') { continue; } $lines[] = strtoupper((string) ($file['type'] ?? 'file')) . ' ' . $url; } if ($lines === []) { return $query; } return $query . "\n\n\n" . "以下附件无法随本次请求送达,只提供来源地址;无法读取的附件必须在结论中明确标注为信息缺口。\n" . implode("\n", $lines) . "\n"; } private static function isValidRemoteFileUrl(string $url): bool { if ($url === '' || preg_match('/[\x00-\x20\x7f]/', $url)) { return false; } $parts = parse_url($url); return is_array($parts) && in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true) && trim((string) ($parts['host'] ?? '')) !== '' && !isset($parts['user']) && !isset($parts['pass']); } 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; } /** * Decide whether an ambiguous base URL should be tried with the other wire * protocol. Only a missing/unsupported endpoint is a blocking-mode protocol * signal. A 2xx stream with no delivered delta but no valid terminal frame * is also safe to retry. Business validation, authentication, rate-limit and * server failures retain their original diagnosis instead of being hidden. * * @param array $response */ private static function shouldTryNextProtocol(array $response, bool $streaming): bool { if ((int) ($response['errno'] ?? 0) !== 0) { return false; } if (!empty($response['client_aborted']) || !empty($response['callback_error']) || in_array(self::responseUpstreamReason($response), [ 'authentication', 'quota', 'unavailable', 'cancelled', 'inputs_invalid', 'file_type', 'file_size', 'file_count', 'file_access', ], true)) return false; $httpCode = (int) ($response['http_code'] ?? 0); if (in_array($httpCode, [404, 405, 501], true)) { return true; } if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) { return false; } return !empty($response['upstream_error']) || empty($response['finished']); } 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 $options = [] ): 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]; } $shouldAbort = is_callable($options['should_abort'] ?? null) ? $options['should_abort'] : null; $clientAborted = false; $notify = self::transferProgressNotifier($options); $submitted = false; $markSubmitted = static function () use (&$submitted, $notify): void { if (!$submitted) { $submitted = true; $notify('submitted'); } }; $curlOptions = [ 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, ], ]; if (isset($options['timeout_ms'])) { $milliseconds = max(1, min($timeout * 1000, (int) $options['timeout_ms'])); $curlOptions[CURLOPT_TIMEOUT_MS] = $milliseconds; $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = min(1000, $milliseconds); } $observeTransfer = $shouldAbort !== null || is_callable($options['on_progress'] ?? null); if ($observeTransfer) { // Dify blocking mode exposes no streaming task id. Compatibility mode // can close its transfer promptly, but cannot claim remote stop acceptance. $notify('submitting'); $progressOption = self::curlProgressOption(); if ($progressOption === null && $shouldAbort !== null) { curl_close($ch); return ['body' => '', 'errno' => -3, 'http_code' => 0, 'client_aborted' => true]; } if ($progressOption !== null) { $curlOptions[CURLOPT_NOPROGRESS] = false; $curlOptions[$progressOption] = static function (...$values) use ($shouldAbort, &$clientAborted, $markSubmitted): int { if (($values[3] ?? 0) > 0 && ($values[4] ?? 0) >= $values[3]) $markSubmitted(); if ($shouldAbort !== null && $shouldAbort()) { $clientAborted = true; return 1; } return 0; }; } $curlOptions[CURLOPT_HEADERFUNCTION] = static function ($handle, string $line) use ($markSubmitted): int { if (preg_match('/^HTTP\/\S+\s+2\d{2}(?:\s|$)/i', trim($line))) $markSubmitted(); return strlen($line); }; } curl_setopt_array($ch, $curlOptions); $responseBody = ''; $errno = -3; $httpCode = 0; try { $responseBody = curl_exec($ch); $errno = curl_errno($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($shouldAbort !== null && $shouldAbort()) $clientAborted = true; } catch (\Throwable $ignored) { $clientAborted = $shouldAbort !== null; } finally { curl_close($ch); } if ($observeTransfer) { if ($clientAborted && is_callable($options['on_stop'] ?? null)) ($options['on_stop'])(false); if (is_callable($options['on_transfer_end'] ?? null)) ($options['on_transfer_end'])(); if (!$clientAborted && ($errno !== 0 || $httpCode >= 400)) $notify('failed'); } $responseBody = is_string($responseBody) ? $responseBody : ''; $metadata = self::errorBodyMetadata($responseBody); return [ 'body' => $responseBody, 'errno' => $errno, 'http_code' => $httpCode, 'client_aborted' => $clientAborted, ] + $metadata; } private static function transferProgressNotifier(array $options): callable { return static function (string $status) use ($options): void { if (!is_callable($options['on_progress'] ?? null)) return; $items = $options['attachment_items'] ?? []; foreach ($items as &$item) { if (!in_array($item['status'], ['omitted', 'skipped'], true)) $item['status'] = $status; } unset($item); $sections = self::requiredSections($options, $status); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; if ($sections !== []) $summary['required_sections'] = $sections; ($options['on_progress'])(['phase' => 'attachments', 'status' => $status, 'message' => $status === 'submitted' ? '资料正文与附件引用已提交,等待模型;远程附件读取进度不可观测' : ($status === 'failed' ? '本次资料请求提交失败' : '正在提交资料正文与附件引用'), 'source_summary' => $summary, 'required_sections' => $sections, 'source_form' => $options['source_form'] ?? 'full_context', 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] !== 'omitted')), 'items' => $items] + self::attachmentOutcomeCounts($items)); if ($status === 'submitted') ($options['on_progress'])([ 'phase' => 'upstream', 'status' => 'waiting', 'message' => '资料请求已提交,正在等待模型正文', ]); }; } /** * libcurl 7.32+ exposes CURLOPT_XFERINFOFUNCTION, while CentOS/RHEL 7 commonly * ships libcurl 7.29 with only CURLOPT_PROGRESSFUNCTION. Resolve the option * by name so loading this class never evaluates an undefined PHP constant. */ private static function curlProgressOption(): ?int { foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) { if (defined($name)) { return (int) constant($name); } } return null; } /** * @param array $payload * @param callable(string):mixed $onDelta * @param callable():bool|null $shouldAbort * @return array{ * errno:int,http_code:int,content:string,message_id:string,emitted:bool, * upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool * } */ private static function sendStreamRequest( string $protocol, string $url, array $payload, string $apiKey, int $timeout, callable $onDelta, ?callable $shouldAbort, array $options = [] ): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { return self::emptyStreamResponse(-1); } $ch = curl_init(); if ($ch === false) { return self::emptyStreamResponse(-2); } $buffer = ''; $state = self::newStreamState(); $upstreamTiming = is_callable($options['on_upstream_timing'] ?? null) ? new DifyStreamTiming() : null; $state['timing'] = $upstreamTiming; $state['on_task'] = $options['on_task'] ?? null; $state['on_terminal'] = $options['on_terminal'] ?? null; $notify = self::transferProgressNotifier($options); $notify('submitting'); $submitted = false; $markSubmitted = static function () use (&$submitted, $notify): void { if (!$submitted) { $submitted = true; $notify('submitted'); } }; $cancelAt = null; $stopAttempted = false; $stopAccepted = false; $attemptStop = static function () use ($options, $protocol, $url, $payload, $apiKey, &$state, &$cancelAt, &$stopAttempted, &$stopAccepted): void { if ($protocol !== 'dify' || $state['task_id'] === '' || $state['upstream_terminal_confirmed']) return; $remainingMs = (int) floor((self::CANCEL_DRAIN_SECONDS - (hrtime(true) / 1e9 - $cancelAt)) * 1000); if ($remainingMs <= 0) return; $performStop = static function () use ($url, $payload, $apiKey, &$state, $remainingMs): bool { $response = self::sendRequest(rtrim($url, '/') . '/' . rawurlencode($state['task_id']) . '/stop', ['user' => (string) ($payload['user'] ?? '')], $apiKey, 1, ['timeout_ms' => min(1000, $remainingMs)]); $body = json_decode((string) ($response['body'] ?? ''), true); return ($response['errno'] ?? -1) === 0 && ($response['http_code'] ?? 0) === 200 && ($body['result'] ?? '') === 'success'; }; if (is_callable($options['on_cancel_task'] ?? null)) { // Both workers use the same atomic task claim; a pending owner // keeps this generation connection alive while the peer stops it. $outcome = ($options['on_cancel_task'])($state['task_id'], $performStop); $stopAccepted = $stopAccepted || (is_array($outcome) && !empty($outcome['accepted'])); } elseif (!$stopAttempted) { $stopAttempted = true; $stopAccepted = $performStop(); } }; $abort = static function () use ($shouldAbort, $protocol, &$state, &$cancelAt, $attemptStop): bool { if ($cancelAt === null && !$state['callback_error'] && ($shouldAbort === null || !$shouldAbort())) return false; $cancelAt = $cancelAt ?? hrtime(true) / 1e9; $state['client_aborted'] = true; $state['discard_deltas'] = true; if ($protocol !== 'dify') return true; // One absolute cancellation budget includes stop I/O and metadata // discovery. Silent headers/body cannot prolong it: the multi pump // polls even when libcurl's progress callback has no network events. $budget = $state['task_id'] === '' ? self::CANCEL_METADATA_SECONDS : self::CANCEL_DRAIN_SECONDS; if (hrtime(true) / 1e9 - $cancelAt >= $budget) return true; $attemptStop(); // workflow_finished confirms execution state, but Dify can still // yield message_end and persist cleanup after that frame. Preserve // the connection until natural EOF or this same absolute budget. return $state['callback_error'] || hrtime(true) / 1e9 - $cancelAt >= $budget; }; $forward = static function (string $delta) use ($shouldAbort, $onDelta, &$state, &$cancelAt): bool { if ($cancelAt !== null || ($shouldAbort !== null && $shouldAbort())) { $state['discard_deltas'] = true; return true; // Consume metadata/terminal frames without accumulating cancelled text. } $accepted = $onDelta($delta) !== false; if (!$accepted && $shouldAbort !== null && $shouldAbort()) { $state['discard_deltas'] = true; return true; } return $accepted; }; $responseCode = 0; $errorBody = ''; $errorBodyOverflow = false; $header = static function ($handle, string $line) use (&$responseCode, &$errorBody, &$errorBodyOverflow, $markSubmitted, $upstreamTiming): int { if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) { $responseCode = (int) $matches[1]; $upstreamTiming?->headers($responseCode); $errorBody = ''; $errorBodyOverflow = false; if ($responseCode >= 200 && $responseCode < 300) $markSubmitted(); } return strlen($line); }; $write = static function ($handle, string $chunk) use ( $protocol, &$buffer, &$state, &$responseCode, &$errorBody, &$errorBodyOverflow, $upstreamTiming, $forward, $abort ): int { $upstreamTiming?->body(strlen($chunk)); if ($responseCode < 200 || $responseCode >= 300) { // Keep a bounded JSON error only until its safe code/reason has // been extracted. Never feed HTTP error bodies to the SSE parser. if (!$errorBodyOverflow && strlen($errorBody) + strlen($chunk) <= self::MAX_ERROR_BODY_BYTES) { $errorBody .= $chunk; } else { $errorBody = ''; $errorBodyOverflow = true; } return strlen($chunk); } self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $forward); if ($abort()) { $state['client_aborted'] = true; return 0; } return $state['callback_error'] ? 0 : strlen($chunk); }; $progress = static function (...$values) use ($abort, $markSubmitted): int { if (($values[3] ?? 0) > 0 && ($values[4] ?? 0) >= $values[3]) $markSubmitted(); if ($abort()) { return 1; } return 0; }; $curlOptions = [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => false, 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: text/event-stream', 'Authorization: Bearer ' . $apiKey, ], CURLOPT_HEADERFUNCTION => $header, CURLOPT_WRITEFUNCTION => $write, ]; $progressOption = self::curlProgressOption(); if ($progressOption !== null) { $curlOptions[CURLOPT_NOPROGRESS] = false; $curlOptions[$progressOption] = $progress; } curl_setopt_array($ch, $curlOptions); $errno = -3; $httpCode = $responseCode; try { if ($shouldAbort !== null) { $errno = self::executeCancellableStream($ch, $abort); } else { curl_exec($ch); $errno = curl_errno($ch); } $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); } catch (\Throwable $ignored) { // Observer/storage errors must fail closed and still stop a known task. $state['callback_error'] = true; } finally { // A callback failure may escape libcurl before the regular cancel // poll. Make the coordinated stop attempt before closing this handle. try { $abort(); } catch (\Throwable $ignored) {} if ($upstreamTiming !== null) { try { $upstreamTiming->transport(['connect_ms' => curl_getinfo($ch, CURLINFO_CONNECT_TIME) * 1000, 'tls_ms' => curl_getinfo($ch, CURLINFO_APPCONNECT_TIME) * 1000, 'header_ttfb_ms' => curl_getinfo($ch, CURLINFO_STARTTRANSFER_TIME) * 1000, 'transfer_ms' => curl_getinfo($ch, CURLINFO_TOTAL_TIME) * 1000, 'http_status' => $httpCode, 'curl_errno' => $errno]); } catch (\Throwable $ignored) {} } curl_close($ch); } if (!$state['callback_error']) { try { self::consumeStreamBytes($protocol, $buffer, '', $state, $forward, true); } catch (\Throwable $ignored) { $state['callback_error'] = true; } } $cancelled = $state['client_aborted'] || $state['callback_error']; try { $cancelled = $cancelled || ($shouldAbort !== null && $shouldAbort()); } catch (\Throwable $ignored) { $cancelled = true; } if ($cancelled) { $state['client_aborted'] = true; if (is_callable($options['on_stop'] ?? null)) ($options['on_stop'])($stopAccepted, $state['task_id']); } if (is_callable($options['on_transfer_end'] ?? null)) ($options['on_transfer_end'])($state['task_id']); if (!$cancelled && ($errno !== 0 || $httpCode >= 400)) $notify('failed'); if ($httpCode < 200 || $httpCode >= 300) { $metadata = self::errorBodyMetadata($errorBody); $state['upstream_code'] = $metadata['upstream_code']; $state['upstream_reason'] = $metadata['upstream_reason']; } unset($errorBody); if ($upstreamTiming !== null) { try { ($options['on_upstream_timing'])($upstreamTiming); } catch (\Throwable $ignored) {} } return [ 'errno' => $errno, 'http_code' => $httpCode, 'content' => $state['content'], 'message_id' => $state['message_id'], 'emitted' => $state['emitted'], 'upstream_error' => $state['upstream_error'], 'upstream_code' => $state['upstream_code'], 'upstream_reason' => $state['upstream_reason'], 'client_aborted' => $state['client_aborted'], 'callback_error' => $state['callback_error'], 'finished' => $state['finished'], 'upstream_terminal_confirmed' => $state['upstream_terminal_confirmed'], 'upstream_terminal_status' => $state['upstream_terminal_status'], ]; } /** Poll cancellation at most 50ms apart, including a silent upstream. */ private static function executeCancellableStream($ch, callable $abort): int { $multi = curl_multi_init(); $added = false; $errno = 0; try { if (curl_multi_add_handle($multi, $ch) !== CURLM_OK) return -3; $added = true; do { if ($abort()) return CURLE_ABORTED_BY_CALLBACK; do { $result = curl_multi_exec($multi, $running); } while ($result === CURLM_CALL_MULTI_PERFORM); while (($info = curl_multi_info_read($multi)) !== false) { if (($info['handle'] ?? null) === $ch) $errno = (int) $info['result']; } if ($result !== CURLM_OK) return -3; if (!$running) break; if ($abort()) return CURLE_ABORTED_BY_CALLBACK; if (curl_multi_select($multi, 0.05) === -1) usleep(1000); } while (true); return $errno; } finally { if ($added) curl_multi_remove_handle($multi, $ch); curl_multi_close($multi); } } /** * @return array{ * content:string,message_id:string,emitted:bool,upstream_error:bool, * client_aborted:bool,callback_error:bool,finished:bool * } */ private static function newStreamState(): array { return [ 'content' => '', 'message_id' => '', 'emitted' => false, 'upstream_error' => false, 'upstream_code' => '', 'upstream_reason' => '', 'client_aborted' => false, 'callback_error' => false, 'finished' => false, 'task_id' => '', 'discard_deltas' => false, 'upstream_terminal_confirmed' => false, 'upstream_terminal_status' => '', ]; } /** * 上游错误码只保留可枚举的短标识(如 invalid_param),杜绝把上游文案或 * 患者资源地址带进日志。 * * @param mixed $code */ private static function cleanUpstreamCode($code): string { if (!is_string($code)) { return ''; } return preg_match('/^[a-z0-9_.-]{1,64}$/i', $code) === 1 ? $code : ''; } /** * 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。 * * @param array $state * @param callable(string):mixed $onDelta */ private static function consumeStreamBytes( string $protocol, string &$buffer, string $chunk, array &$state, callable $onDelta, bool $final = false ): void { $buffer .= $chunk; // Keep CRLF atomic: otherwise regex backtracking can count a single CRLF // as two line breaks and decode a multi-line event before it is complete. while (preg_match('/(?>\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) { $delimiter = $match[0][0]; $offset = $match[0][1]; $frame = substr($buffer, 0, $offset); $buffer = (string) substr($buffer, $offset + strlen($delimiter)); self::consumeStreamFrame($protocol, $frame, $state, $onDelta); } if ($final && trim($buffer) !== '') { self::consumeStreamFrame($protocol, $buffer, $state, $onDelta); $buffer = ''; } } /** * @param array $state * @param callable(string):mixed $onDelta */ private static function consumeStreamFrame( string $protocol, string $frame, array &$state, callable $onDelta ): void { if ($state['callback_error']) { return; } $dataLines = []; foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) { if ($line === '' || str_starts_with($line, ':')) { continue; } if (str_starts_with($line, 'data:')) { $dataLines[] = ltrim(substr($line, 5), ' '); } } if ($dataLines === []) { return; } $data = implode("\n", $dataLines); if ($data === '[DONE]') { $state['finished'] = true; return; } $decoded = json_decode($data, true); if (!is_array($decoded)) { return; } $delta = ''; if ($protocol === 'dify') { $taskId = $decoded['task_id'] ?? ''; if (is_string($taskId) && preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $taskId) === 1) { // One HTTP generation owns one task. Ignore foreign late metadata. if ($state['task_id'] !== '' && $taskId !== $state['task_id']) return; if ($state['task_id'] === '') { $state['task_id'] = $taskId; if (is_callable($state['on_task'] ?? null)) ($state['on_task'])($taskId); } } $event = strtolower((string) ($decoded['event'] ?? '')); if (($state['timing'] ?? null) instanceof DifyStreamTiming) $state['timing']->frame($decoded, $protocol); if ($event === 'workflow_finished') { $rawStatus = $decoded['data']['status'] ?? ''; $status = is_string($rawStatus) ? strtolower($rawStatus) : ''; if ($status === 'cancelled') $status = 'stopped'; if (!$state['upstream_terminal_confirmed'] && $state['task_id'] !== '' && in_array($status, ['stopped', 'succeeded', 'failed'], true)) { $state['upstream_terminal_confirmed'] = true; $state['upstream_terminal_status'] = $status; if (is_callable($state['on_terminal'] ?? null)) ($state['on_terminal'])($state['task_id'], $status); } return; } // Keep observing workflow_finished even after message_end; the latter // alone is stream completion, never proof that a workflow stopped. if ($state['finished'] || $state['upstream_error']) return; if ($event === 'message_end') { $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); $state['finished'] = true; return; } if ($event === 'error') { $state['upstream_error'] = true; $metadata = self::upstreamErrorMetadata($decoded); $state['upstream_code'] = $metadata['upstream_code']; $state['upstream_reason'] = $metadata['upstream_reason']; return; } if (!in_array($event, ['message', 'agent_message'], true)) { return; } $delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : ''; $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); } else { if ($state['finished'] || $state['upstream_error']) return; if (($state['timing'] ?? null) instanceof DifyStreamTiming) $state['timing']->frame($decoded, $protocol); if (is_array($decoded['error'] ?? null)) { $state['upstream_error'] = true; $metadata = self::upstreamErrorMetadata($decoded); $state['upstream_code'] = $metadata['upstream_code']; $state['upstream_reason'] = $metadata['upstream_reason']; return; } $delta = self::extractStreamDelta($decoded); $state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']); } if ($delta === '') { return; } try { $accepted = $onDelta($delta); if ($accepted === false) { $state['callback_error'] = true; return; } } catch (\Throwable $e) { $state['callback_error'] = true; return; } if ($state['discard_deltas']) return; if (($state['timing'] ?? null) instanceof DifyStreamTiming) $state['timing']->delivered(); $state['content'] .= $delta; $state['emitted'] = true; } /** @param array $decoded */ private static function extractStreamDelta(array $decoded): string { $content = $decoded['choices'][0]['delta']['content'] ?? ''; if (is_string($content)) { return $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 implode('', $parts); } /** * 纯解析测试入口:生产流与测试使用同一逐字节解码路径。 * * @param array $chunks * @return array{content:string,deltas:array,message_id:string,finished:bool,upstream_error:bool} */ private static function decodeStreamChunks(string $protocol, array $chunks): array { $buffer = ''; $state = self::newStreamState(); $deltas = []; $onDelta = static function (string $delta) use (&$deltas): void { $deltas[] = $delta; }; foreach ($chunks as $chunk) { self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta); } self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true); return [ 'content' => $state['content'], 'deltas' => $deltas, 'message_id' => $state['message_id'], 'finished' => $state['finished'], 'upstream_error' => $state['upstream_error'], ]; } /** * @return array{ * errno:int,http_code:int,content:string,message_id:string,emitted:bool, * upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool * } */ private static function emptyStreamResponse(int $errno): array { return [ 'errno' => $errno, 'http_code' => 0, 'content' => '', 'message_id' => '', 'emitted' => false, 'upstream_error' => false, 'upstream_code' => '', 'upstream_reason' => '', 'client_aborted' => false, 'callback_error' => false, 'finished' => false, ]; } /** * @param array $response * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} */ private static function formatStreamResponse(array $response, float $startedAt): array { $latencyMs = self::elapsedMilliseconds($startedAt); if (!empty($response['client_aborted'])) { return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs); } if (!empty($response['callback_error'])) { return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs); } $errno = (int) ($response['errno'] ?? 0); 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); } $httpCode = (int) ($response['http_code'] ?? 0); if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) { return self::formatUpstreamRejection($response, $latencyMs); } if (empty($response['finished'])) { return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs); } $content = (string) ($response['content'] ?? ''); if (trim($content) === '') { return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs); } return [ 'ok' => true, 'content' => $content, 'message_id' => (string) ($response['message_id'] ?? ''), 'latency_ms' => $latencyMs, ]; } /** * @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 >= 400 || $httpCode < 200 || !empty($response['upstream_error']) || (is_array($decoded) && is_array($decoded['error'] ?? null))) { return self::formatUpstreamRejection($response, $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, 'model_name' => is_string($decoded['model'] ?? null) ? $decoded['model'] : null, 'usage' => self::normalizedUsage($decoded['usage'] ?? $decoded['metadata']['usage'] ?? null), ]; } private static function formatUpstreamRejection(array $response, int $latencyMs): array { $httpCode = (int) ($response['http_code'] ?? 0); if (in_array($httpCode, [401, 403], true)) { return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); } if ($httpCode === 429 || $httpCode >= 500) { return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); } $reason = self::responseUpstreamReason($response); if ($reason === 'authentication') return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); if (in_array($reason, ['quota', 'unavailable'], true)) return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); if ($reason === 'cancelled') return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', $latencyMs); return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); } /** Missing provider usage remains unknown, never a fabricated zero-token charge. */ private static function normalizedUsage($usage): array { $result = []; foreach (['prompt_tokens', 'completion_tokens', 'total_tokens'] as $key) { $value = is_array($usage) ? ($usage[$key] ?? null) : null; $result[$key] = is_numeric($value) && (float) $value >= 0 ? (int) $value : null; } return $result; } /** @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); } /** * 记录上游失败的结构化定位信息。按项目约定,绝不写入凭据、上游主机名或 * 响应正文,只保留可用于排障的协议、路径、状态码和请求规模。 * * @param array $requestSpec * @param array $response * @param array> $files * @param array $formatted */ private static function logUpstreamFailure( array $requestSpec, array $response, string $query, array $files, array $formatted ): void { if (!empty($formatted['ok'])) { return; } $url = (string) ($requestSpec['url'] ?? ''); $context = [ 'protocol' => (string) ($requestSpec['protocol'] ?? ''), 'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''), 'http_code' => (int) ($response['http_code'] ?? 0), 'curl_errno' => (int) ($response['errno'] ?? 0), // 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。 'upstream_code' => self::responseUpstreamCode($response), 'upstream_reason' => self::responseUpstreamReason($response), 'query_bytes' => strlen($query), 'file_count' => count($files), 'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'), 'latency_ms' => (int) ($formatted['latency_ms'] ?? 0), ]; // SSE controllers exit before normal buffered-log flushing. Write this // allowlisted record immediately, while retaining lightweight test stubs. try { $message = 'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES); if (method_exists(Log::class, '__callStatic') || method_exists(Log::class, 'write')) { Log::write($message, 'warning'); } else { Log::warning($message); } } catch (\Throwable $ignored) { // Telemetry must never replace the actual upstream result. } } /** * @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, ]; } }