Files
zyt/server/app/common/service/DifyChatService.php
T
2026-09-10 15:19:17 +08:00

1290 lines
49 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 FILE_REJECTION_CODES = [400, 413, 415, 422];
/**
* @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
{
$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 服务配置无效');
}
// 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 模型配置无效');
}
$strictFiles = !empty($options['strict_files']);
$normalized = self::normalizeFiles($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;
$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 = [];
foreach ($requestSpecs as $index => $requestSpec) {
// 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'])) {
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)
);
}
$response = self::sendRequest(
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout
);
$lastResponse = $response;
$lastSpec = $requestSpec;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
$inputRejected = $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'])) {
if ($strictFiles) {
$formatted['transmitted_file_count'] = count($attempt['files']);
$formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? '');
}
return $formatted;
}
// Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整
// 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。
if ($inputIndex === 0 && $inputRejected) {
continue;
}
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 {
$config = config('prescription_ai') ?: [];
if (empty($config['enable'])) {
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
}
$modelConfig = self::resolveProfileConfig($config, $profile);
if ($modelConfig === null) {
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
}
$baseUrl = trim((string) ($config['base_url'] ?? ''));
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
$apiKey = trim($rawApiKey);
if ($baseUrl === '' || $apiKey === '') {
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
}
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
if (!function_exists('curl_init')) {
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
}
$model = trim((string) ($modelConfig['name'] ?? ''));
if ($model === '') {
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$normalized = self::normalizeFiles($files, self::maxFiles($config, $modelConfig));
$startedAt = microtime(true);
$formatted = null;
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) 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 = [];
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendStreamRequest(
$requestSpec['protocol'],
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout,
$onDelta,
$shouldAbort
);
$lastResponse = $response;
$lastSpec = $requestSpec;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
$inputRejected = $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);
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
if (!empty($formatted['ok'])) {
return $formatted;
}
if (
$inputIndex === 0
&& $inputRejected
&& empty($lastResponse['emitted'])
) {
continue;
}
break;
}
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
if (!empty($lastResponse['emitted']) || !$fileRejected) {
return $formatted;
}
}
return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt));
}
/**
* @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,
],
];
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);
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
);
}
/** @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;
}
$decoded = json_decode((string) $response['body'], true);
return is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : '';
}
/**
* @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
{
if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) {
return false;
}
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;
}
$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 {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($body === false) {
return ['body' => '', 'errno' => -1, 'http_code' => 0];
}
$ch = curl_init();
if ($ch === false) {
return ['body' => '', 'errno' => -2, 'http_code' => 0];
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$responseBody = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'body' => is_string($responseBody) ? $responseBody : '',
'errno' => $errno,
'http_code' => $httpCode,
];
}
/**
* 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 {
$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();
$responseCode = 0;
$header = static function ($handle, string $line) use (&$responseCode): int {
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
$responseCode = (int) $matches[1];
}
return strlen($line);
};
$write = static function ($handle, string $chunk) use (
$protocol,
&$buffer,
&$state,
&$responseCode,
$onDelta,
$shouldAbort
): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 0;
}
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.
return strlen($chunk);
}
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
return $state['callback_error'] ? 0 : strlen($chunk);
};
$progress = static function (...$unused) use (&$state, $shouldAbort): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
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);
curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$state['client_aborted'] && !$state['callback_error']) {
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
}
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'],
'client_aborted' => $state['client_aborted'],
'callback_error' => $state['callback_error'],
'finished' => $state['finished'],
];
}
/**
* @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' => '',
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* 上游错误码只保留可枚举的短标识(如 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;
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['finished'] || $state['upstream_error'] || $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') {
$event = strtolower((string) ($decoded['event'] ?? ''));
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;
// 只留可枚举的错误码用于排障;message 可能含患者资源地址,不落日志。
$state['upstream_code'] = self::cleanUpstreamCode($decoded['code'] ?? '');
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 {
$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;
}
$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' => '',
'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 === 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);
}
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 === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
}
if (!is_array($decoded)) {
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
}
$answer = self::extractContent($decoded);
if ($answer === '') {
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
}
return [
'ok' => true,
'content' => $answer,
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
'latency_ms' => $latencyMs,
'model_name' => is_string($decoded['model'] ?? null) ? $decoded['model'] : null,
'usage' => self::normalizedUsage($decoded['usage'] ?? $decoded['metadata']['usage'] ?? null),
];
}
/** 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),
'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)
);
}
/**
* @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,
];
}
}