Files
zyt/server/app/common/service/DifyChatService.php
T
2026-10-23 11:39:56 +08:00

2048 lines
98 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use think\facade\Log;
/**
* 处方/诊单 AI 上游客户端。
*
* 兼容 Dify blocking chat-messages 与 OpenAI-compatible chat completions。
* 地址和凭据只从服务端 prescription_ai 配置读取,不进入响应、日志或请求正文。
*/
class DifyChatService
{
/** @var array<int,string> */
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<string,mixed> $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<string,mixed> $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<string,mixed> $config
* @return array<string,mixed>|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<string,mixed> $inputs
* @param array<int,array<string,string>> $files 随请求送达的附件
* @param array<int,array<string,string>> $omitted 超出上游数量上限、只能写进清单的附件
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
*/
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<int,array<string,string>> $files
* @return string|array<int,array<string,mixed>>
*/
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<int,array<string,string>> $files
* @return array<int,array<string,string>>
*/
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<int,mixed> $files
* @return array{
* kept:array<int,array{type:string,transfer_method:string,url:string}>,
* dropped:array<int,array{type:string,transfer_method:string,url:string}>
* }
*/
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<string,mixed> $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<int,array<string,string>> $files
* @param array<int,array<string,string>> $dropped
* @return array<int,array{files:array<int,array<string,string>>,omitted:array<int,array<string,string>>}>
*/
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<string,mixed> $inputs
* @return array<int,array<string,mixed>>
*/
private static function buildInputAttemptPlan(array $inputs): array
{
return $inputs === [] ? [[]] : [$inputs, []];
}
/**
* @param array<string,mixed> $response
* @param array<string,mixed> $requestSpec
* @param array<string,mixed> $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<string,mixed> $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<int,array<string,string>> $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<string,mixed> $response
* @param array<int,array<string,string>> $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<int,array<string,string>> $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<ATTACHMENTS_NOT_INLINE>\n"
. "以下附件无法随本次请求送达,只提供来源地址;无法读取的附件必须在结论中明确标注为信息缺口。\n"
. implode("\n", $lines)
. "\n</ATTACHMENTS_NOT_INLINE>";
}
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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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<int,string> $chunks
* @return array{content:string,deltas:array<int,string>,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<string,mixed> $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<string,mixed> $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<string,mixed> $requestSpec
* @param array<string,mixed> $response
* @param array<int,array<string,string>> $files
* @param array<string,mixed> $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,
];
}
}