This commit is contained in:
Your Name
2026-10-23 10:40:43 +08:00
parent e974d3cfa5
commit a3d2d3170d
14 changed files with 1660 additions and 83 deletions
+290 -69
View File
@@ -33,6 +33,13 @@ class DifyChatService
*/
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}
@@ -107,7 +114,7 @@ class DifyChatService
$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']);
if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error'], $files);
$files = $checked['files'];
}
$strictFiles = !empty($options['strict_files']) || $requiredFiles;
@@ -119,6 +126,7 @@ class DifyChatService
}
$startedAt = microtime(true);
$formatted = null;
$uploadCache = [];
$attemptPlan = $strictFiles
? [['files' => $normalized['kept'], 'omitted' => []]]
@@ -143,13 +151,14 @@ class DifyChatService
);
$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'], $attempt['files'])) {
if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件');
if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']);
return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件');
}
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
@@ -162,20 +171,28 @@ class DifyChatService
);
}
$staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey,
$remainingTimeout, $shouldAbort, $options, $config, $uploadCache);
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,
$options + ['attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])]
$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;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
$inputRejected = $inputRejected || self::isInputRejection(
// A corrected inputs attempt or protocol response owns its own diagnosis.
$fileRejected = self::isFileRejection($response, $attempt['files'], $requiredFiles);
$inputRejected = self::isInputRejection(
$response,
$requestSpec,
$attemptInputs
@@ -203,15 +220,15 @@ class DifyChatService
$formatted['transmitted_file_count'] = count($attempt['files']);
$formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? '');
}
if ($requiredFiles) $formatted += self::finalTransferManifest($options, $attempt['files'], (string) ($lastSpec['protocol'] ?? ''));
if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? ''));
return $formatted;
}
// Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整
// 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。
// 放在 query 中,因此明确的 inputs 校验错误可用空对象重试一次。
if ($inputIndex === 0 && $inputRejected) {
continue;
}
if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($options, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求');
if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求', $attempt['files'], (string) ($lastSpec['protocol'] ?? ''), (int) ($formatted['latency_ms'] ?? 0));
break;
}
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
@@ -288,7 +305,7 @@ class DifyChatService
$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']);
if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error'], $files);
$files = $checked['files'];
}
$strictFiles = !empty($options['strict_files']) || $requiredFiles;
@@ -298,6 +315,7 @@ class DifyChatService
}
$startedAt = microtime(true);
$formatted = null;
$uploadCache = [];
$attemptPlan = $strictFiles
? [['files' => $normalized['kept'], 'omitted' => []]]
@@ -322,13 +340,14 @@ class DifyChatService
);
$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'], $attempt['files'])) {
if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件');
if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']);
return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件');
}
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
@@ -341,6 +360,13 @@ class DifyChatService
);
}
$staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey,
$remainingTimeout, $shouldAbort, $options, $config, $uploadCache);
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'],
@@ -349,14 +375,14 @@ class DifyChatService
$remainingTimeout,
$onDelta,
$shouldAbort,
$options + ['profile' => $profile, 'user' => $user,
$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 = $fileRejected || self::isFileRejection($response, $attempt['files']);
$inputRejected = $inputRejected || self::isInputRejection(
$fileRejected = self::isFileRejection($response, $attempt['files'], $requiredFiles);
$inputRejected = self::isInputRejection(
$response,
$requestSpec,
$attemptInputs
@@ -387,7 +413,7 @@ class DifyChatService
$formatted['transmitted_file_count'] = count($attempt['files']);
$formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? '');
}
if ($requiredFiles) $formatted += self::finalTransferManifest($options, $attempt['files'], (string) ($lastSpec['protocol'] ?? ''));
if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? ''));
return $formatted;
}
if (
@@ -397,7 +423,7 @@ class DifyChatService
) {
continue;
}
if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($options, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求');
if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求', $attempt['files'], (string) ($lastSpec['protocol'] ?? ''), (int) ($formatted['latency_ms'] ?? 0));
break;
}
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
@@ -425,6 +451,72 @@ class DifyChatService
&& ($body['result'] ?? '') === 'success';
}
/** Stage each mandatory Dify file once; a failed upload cannot start generation. */
private static function prepareRequiredUpload(array $spec, array $files, string $user, string $apiKey,
int $timeout, ?callable $shouldAbort, array $options, array $config, array &$cache): array
{
if (empty($options['required_files']) || $files === [] || ($spec['protocol'] ?? '') !== 'dify'
|| ($config['required_attachment_transport'] ?? '') !== 'upload') {
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'], true)) return;
$items[$index - 1]['status'] = $status;
$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',
]);
};
$started = microtime(true);
$uploaded = DifyRequiredAttachmentUpload::prepare($spec['url'], $apiKey, $user, $files,
$timeout, $shouldAbort, $uploadOptions);
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));
}
if (!is_array($uploaded['files'] ?? null) || count($uploaded['files']) !== count($files)) {
return self::requiredFilesFailure($options, 'REQUIRED_FILES_UPLOAD_FAILED', '必需附件上传不完整,本次未提交分析', $files);
}
$cache[$cacheKey] = $uploaded['files'];
}
$spec['payload']['files'] = $cache[$cacheKey];
return ['ok' => true, 'request_spec' => $spec, 'options' => $options];
}
/** Mandatory clinical files have no lossy normalization or text-only fallback. */
private static function requiredFiles(array $files): array
{
@@ -468,16 +560,33 @@ class DifyChatService
return $result;
}
private static function requiredFilesFailure(array $options, string $code, string $message): array
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::attachmentItems($labels, [], $protocol);
foreach ($items as &$item) $item['status'] = 'failed';
unset($item);
$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];
if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([
'phase' => 'attachments', 'status' => 'failed', 'message' => $message,
'required_sections' => $sections, 'source_summary' => $summary,
]);
return self::error($code, $message);
] + $manifest);
return self::error($code, $message, $latencyMs) + $manifest;
}
private static function finalTransferManifest(array $options, array $files, string $protocol): array
@@ -490,7 +599,7 @@ class DifyChatService
unset($item);
return ['required_sections' => $sections, 'source_summary' => $summary,
'items' => $items, 'attachment_count' => count($files),
'attachment_transfer' => 'remote_url_references', 'transfer_manifest_final' => true];
'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true];
}
/** Safe labels only; submitted means request references sent, never remote file reading. */
@@ -747,24 +856,9 @@ class DifyChatService
}
$httpCode = (int) ($response['http_code'] ?? 0);
if (in_array($httpCode, [413, 422], true)) {
return true;
}
$upstreamCode = strtolower(self::responseUpstreamCode($response));
if ($httpCode === 400) {
// 标准 Dify 会给出 invalid_param;部分兼容网关只保留 400,因此空码
// 也允许一次无 inputs 重试。额度、模型或应用状态错误不能重复提交。
return $upstreamCode === ''
|| in_array($upstreamCode, ['invalid_param', 'payload_too_large', 'request_too_large'], true);
}
return !empty($response['upstream_error'])
&& in_array(
$upstreamCode,
['invalid_param', 'payload_too_large', 'request_too_large'],
true
);
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 */
@@ -774,8 +868,73 @@ class DifyChatService
if ($upstreamCode !== '' || !isset($response['body'])) {
return $upstreamCode;
}
$decoded = json_decode((string) $response['body'], true);
return is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : '';
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];
}
/**
@@ -795,11 +954,23 @@ class DifyChatService
* @param array<string,mixed> $response
* @param array<int,array<string,string>> $files
*/
private static function isFileRejection(array $response, array $files): bool
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;
}
@@ -871,6 +1042,11 @@ class DifyChatService
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;
@@ -995,12 +1171,14 @@ class DifyChatService
if (!$clientAborted && ($errno !== 0 || $httpCode >= 400)) $notify('failed');
}
$responseBody = is_string($responseBody) ? $responseBody : '';
$metadata = self::errorBodyMetadata($responseBody);
return [
'body' => is_string($responseBody) ? $responseBody : '',
'body' => $responseBody,
'errno' => $errno,
'http_code' => $httpCode,
'client_aborted' => $clientAborted,
];
] + $metadata;
}
private static function transferProgressNotifier(array $options): callable
@@ -1022,7 +1200,7 @@ class DifyChatService
'required_sections' => $sections,
'source_form' => $options['source_form'] ?? 'full_context',
'evidence_cache_hit' => !empty($options['evidence_cache_hit']),
'attachment_transfer' => 'remote_url_references',
'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references',
'attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] !== 'omitted')),
'items' => $items]);
if ($status === 'submitted') ($options['on_progress'])([
@@ -1141,9 +1319,13 @@ class DifyChatService
return $accepted;
};
$responseCode = 0;
$header = static function ($handle, string $line) use (&$responseCode, $markSubmitted): int {
$errorBody = '';
$errorBodyOverflow = false;
$header = static function ($handle, string $line) use (&$responseCode, &$errorBody, &$errorBodyOverflow, $markSubmitted): int {
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
$responseCode = (int) $matches[1];
$errorBody = '';
$errorBodyOverflow = false;
if ($responseCode >= 200 && $responseCode < 300) $markSubmitted();
}
return strlen($line);
@@ -1153,12 +1335,20 @@ class DifyChatService
&$buffer,
&$state,
&$responseCode,
&$errorBody,
&$errorBodyOverflow,
$forward,
$abort
): int {
if ($responseCode < 200 || $responseCode >= 300) {
// Never decode or forward an error response body. Besides preventing
// leakage, this keeps 404/405 protocol fallback side-effect free.
// 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);
@@ -1231,6 +1421,13 @@ class DifyChatService
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);
return [
'errno' => $errno,
'http_code' => $httpCode,
@@ -1239,6 +1436,7 @@ class DifyChatService
'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'],
@@ -1289,6 +1487,7 @@ class DifyChatService
'emitted' => false,
'upstream_error' => false,
'upstream_code' => '',
'upstream_reason' => '',
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
@@ -1414,8 +1613,9 @@ class DifyChatService
}
if ($event === 'error') {
$state['upstream_error'] = true;
// 只留可枚举的错误码用于排障;message 可能含患者资源地址,不落日志。
$state['upstream_code'] = self::cleanUpstreamCode($decoded['code'] ?? '');
$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)) {
@@ -1425,6 +1625,13 @@ class DifyChatService
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
} else {
if ($state['finished'] || $state['upstream_error']) return;
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']);
}
@@ -1509,6 +1716,7 @@ class DifyChatService
'emitted' => false,
'upstream_error' => false,
'upstream_code' => '',
'upstream_reason' => '',
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
@@ -1544,14 +1752,8 @@ class DifyChatService
}
$httpCode = (int) ($response['http_code'] ?? 0);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
return self::formatUpstreamRejection($response, $latencyMs);
}
if (empty($response['finished'])) {
return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs);
@@ -1593,14 +1795,9 @@ class DifyChatService
}
$decoded = json_decode($response['body'], true);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
if ($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);
@@ -1621,6 +1818,22 @@ class DifyChatService
];
}
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
{
@@ -1684,16 +1897,24 @@ class DifyChatService
'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),
];
// ThinkPHP 文件日志不会自动输出未参与占位符替换的 context;显式序列化
// 这组不含凭据、主机名、患者正文的诊断字段,确保线上日志真正可用。
Log::warning(
'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES)
);
// 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.
}
}
/**