更新
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user