419 lines
18 KiB
PHP
419 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service {
|
|
// Exercise chat() offline without loading runtime configuration or opening a connection.
|
|
function config(string $name): array
|
|
{
|
|
return $GLOBALS['upstreamTestConfig'];
|
|
}
|
|
|
|
function curl_init(): \stdClass
|
|
{
|
|
return new \stdClass();
|
|
}
|
|
|
|
function curl_setopt_array(\stdClass $handle, array $options): bool
|
|
{
|
|
$handle->url = $options[CURLOPT_URL];
|
|
$GLOBALS['upstreamTestRequests'][] = ['url' => $handle->url, 'payload' => json_decode($options[CURLOPT_POSTFIELDS], true)];
|
|
return true;
|
|
}
|
|
|
|
function curl_exec(\stdClass $handle): string
|
|
{
|
|
return json_encode(str_ends_with($handle->url, '/chat-messages')
|
|
? ['answer' => 'offline reply'] : ['choices' => [['message' => ['content' => 'offline reply']]]]);
|
|
}
|
|
|
|
function curl_errno(\stdClass $handle): int { return 0; }
|
|
function curl_getinfo(\stdClass $handle, int $option): int { return 200; }
|
|
function curl_close(\stdClass $handle): void {}
|
|
}
|
|
|
|
namespace {
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\common\service\DifyChatService;
|
|
|
|
function expectSame($expected, $actual, string $message): void
|
|
{
|
|
if ($expected !== $actual) {
|
|
fwrite(STDERR, "FAIL: {$message}\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
function callPrivate(string $name, array $arguments)
|
|
{
|
|
$method = (new ReflectionClass(DifyChatService::class))->getMethod($name);
|
|
return $method->invoke(null, ...$arguments);
|
|
}
|
|
|
|
$generic = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1',
|
|
'model-name',
|
|
['prompt_version' => 'test'],
|
|
'clinical prompt',
|
|
'server-user',
|
|
]);
|
|
expectSame(2, count($generic), 'ambiguous /v1 base should support both protocols');
|
|
expectSame('https://ai.example.test/v1/chat-messages', $generic[0]['url'], 'Dify endpoint');
|
|
expectSame('blocking', $generic[0]['payload']['response_mode'], 'Dify blocking request');
|
|
expectSame('https://ai.example.test/v1/chat/completions', $generic[1]['url'], 'OpenAI endpoint');
|
|
expectSame('model-name', $generic[1]['payload']['model'], 'profile model selection');
|
|
expectSame('clinical prompt', $generic[1]['payload']['messages'][0]['content'], 'OpenAI prompt');
|
|
$serializedSpecs = json_encode($generic, JSON_UNESCAPED_SLASHES) ?: '';
|
|
expectSame(false, str_contains($serializedSpecs, 'api_key'), 'credential field is absent from request bodies');
|
|
expectSame(false, str_contains($serializedSpecs, 'provider'), 'provider override is absent from request bodies');
|
|
expectSame(false, str_contains($serializedSpecs, 'base_url'), 'base URL override is absent from request bodies');
|
|
|
|
// 附件不得让 OpenAI-compatible 回退失效。历史实现遇到报告/录像等非图片附件时只返回
|
|
// Dify 一种协议,网关 404 会直接变成“模型未能处理本次请求”,开处方因此必失败。
|
|
$attachments = [
|
|
['type' => 'document', 'transfer_method' => 'remote_url', 'url' => 'https://cdn.example.test/report.pdf'],
|
|
['type' => 'video', 'transfer_method' => 'remote_url', 'url' => 'https://cdn.example.test/call.mp4'],
|
|
['type' => 'image', 'transfer_method' => 'remote_url', 'url' => 'https://cdn.example.test/tongue.jpg'],
|
|
];
|
|
$withFiles = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1',
|
|
'model-name',
|
|
[],
|
|
'clinical prompt',
|
|
'server-user',
|
|
false,
|
|
$attachments,
|
|
]);
|
|
expectSame(2, count($withFiles), 'non-image attachments must keep the OpenAI fallback available');
|
|
expectSame($attachments, $withFiles[0]['payload']['files'], 'Dify still receives every attachment');
|
|
expectSame(
|
|
'clinical prompt',
|
|
$withFiles[0]['payload']['query'],
|
|
'Dify carries attachments in the file channel, not as a manifest'
|
|
);
|
|
$openAiContent = $withFiles[1]['payload']['messages'][0]['content'];
|
|
expectSame(true, is_array($openAiContent), 'OpenAI content becomes multimodal when attachments exist');
|
|
expectSame('text', $openAiContent[0]['type'], 'attachment manifest travels in the text part');
|
|
expectSame(
|
|
true,
|
|
str_contains($openAiContent[0]['text'], 'https://cdn.example.test/report.pdf'),
|
|
'document attachment is listed instead of being silently dropped'
|
|
);
|
|
expectSame(
|
|
true,
|
|
str_contains($openAiContent[0]['text'], 'https://cdn.example.test/call.mp4'),
|
|
'recording attachment is listed instead of being silently dropped'
|
|
);
|
|
expectSame(
|
|
'https://cdn.example.test/tongue.jpg',
|
|
$openAiContent[1]['image_url']['url'],
|
|
'image attachments stay inline for multimodal reading'
|
|
);
|
|
|
|
$openAi = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat/completions',
|
|
'model-name',
|
|
[],
|
|
'prompt',
|
|
'server-user',
|
|
]);
|
|
expectSame(1, count($openAi), 'explicit OpenAI endpoint should not probe Dify');
|
|
expectSame('openai', $openAi[0]['protocol'], 'explicit OpenAI protocol');
|
|
|
|
$openAiWithFiles = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat/completions',
|
|
'model-name',
|
|
[],
|
|
'prompt',
|
|
'server-user',
|
|
false,
|
|
$attachments,
|
|
]);
|
|
expectSame(1, count($openAiWithFiles), 'explicit OpenAI endpoint stays OpenAI even with attachments');
|
|
expectSame('openai', $openAiWithFiles[0]['protocol'], 'explicit OpenAI protocol with attachments');
|
|
|
|
$difyWithFiles = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat-messages',
|
|
'model-name',
|
|
[],
|
|
'prompt',
|
|
'server-user',
|
|
false,
|
|
$attachments,
|
|
]);
|
|
expectSame(1, count($difyWithFiles), 'explicit Dify endpoint stays Dify with attachments');
|
|
expectSame($attachments, $difyWithFiles[0]['payload']['files'], 'explicit Dify keeps the file channel');
|
|
|
|
$dify = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat-messages',
|
|
'model-name',
|
|
[],
|
|
'prompt',
|
|
'server-user',
|
|
]);
|
|
expectSame(1, count($dify), 'explicit Dify endpoint should not probe OpenAI');
|
|
expectSame('dify', $dify[0]['protocol'], 'explicit Dify protocol');
|
|
|
|
expectSame('Dify answer', callPrivate('extractContent', [['answer' => ' Dify answer ']]), 'Dify response');
|
|
expectSame(
|
|
'OpenAI answer',
|
|
callPrivate('extractContent', [['choices' => [['message' => ['content' => ' OpenAI answer ']]]]]),
|
|
'OpenAI response'
|
|
);
|
|
expectSame(
|
|
'multipart answer',
|
|
callPrivate('extractContent', [['choices' => [['message' => ['content' => [
|
|
['type' => 'text', 'text' => 'multipart '],
|
|
['type' => 'text', 'text' => 'answer'],
|
|
]]]]]]),
|
|
'OpenAI multipart response'
|
|
);
|
|
|
|
// 附件数量必须按上游应用的 file_upload.number_limits 截断。Dify 超限时返回
|
|
// 400 invalid_param 并整单拒绝,历史实现会把患者的全部舌象/报告一次性送上去,
|
|
// 导致该患者的每一次 AI 请求都固定失败(UPSTREAM_REJECTED)。
|
|
$manyFiles = [];
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$manyFiles[] = ['type' => 'image', 'transfer_method' => 'remote_url', 'url' => "https://cdn.example.test/tongue{$i}.jpg"];
|
|
}
|
|
for ($i = 0; $i < 4; $i++) {
|
|
$manyFiles[] = ['type' => 'document', 'transfer_method' => 'remote_url', 'url' => "https://cdn.example.test/report{$i}.pdf"];
|
|
}
|
|
$capped = callPrivate('normalizeFiles', [$manyFiles, 3]);
|
|
expectSame(3, count($capped['kept']), 'the total attachment count is capped, not each type');
|
|
expectSame(6, count($capped['dropped']), 'attachments past the cap are recorded, not discarded');
|
|
expectSame(
|
|
'https://cdn.example.test/tongue3.jpg',
|
|
$capped['dropped'][0]['url'],
|
|
'the earliest attachments are the ones kept'
|
|
);
|
|
expectSame(
|
|
['kept' => [], 'dropped' => []],
|
|
callPrivate('normalizeFiles', [[['type' => 'image', 'url' => 'ftp://cdn.example.test/x.jpg']], 3]),
|
|
'non-http attachments are still rejected outright'
|
|
);
|
|
|
|
$duplicateFiles = [
|
|
['file_id' => 'file:1', 'source_ids' => ['source:1'], 'type' => 'image', 'url' => 'https://cdn.example.test/shared.jpg'],
|
|
['file_id' => 'file:2', 'source_ids' => ['source:2'], 'type' => 'image', 'url' => 'https://cdn.example.test/shared.jpg'],
|
|
['file_id' => 'file:3', 'source_ids' => ['source:3'], 'type' => 'image', 'url' => 'https://cdn.example.test/other.jpg'],
|
|
];
|
|
expectSame(2, count(callPrivate('normalizeFiles', [$duplicateFiles, 3])['kept']), 'default normalization still deduplicates shared URLs');
|
|
$upstreamTestConfig = ['enable' => true, 'base_url' => '', 'timeout' => 30, 'max_files' => 3,
|
|
'models' => ['qwen' => ['name' => 'offline-model', 'api_key' => 'offline-fixture']]];
|
|
foreach (['dify' => 'chat-messages', 'openai' => 'chat/completions'] as $protocol => $endpoint) {
|
|
$upstreamTestConfig['base_url'] = 'https://ai.example.test/v1/' . $endpoint;
|
|
$upstreamTestRequests = [];
|
|
$strictResult = DifyChatService::chat('qwen', [], 'offline query', 'offline-user', $duplicateFiles, ['strict_files' => true]);
|
|
expectSame(true, $strictResult['ok'], 'strict ' . $protocol . ' accepts distinct logical attachments sharing a URL');
|
|
expectSame(1, count($upstreamTestRequests), 'strict ' . $protocol . ' submits the complete batch once');
|
|
$payload = $upstreamTestRequests[0]['payload'];
|
|
$wireUrls = $protocol === 'dify' ? array_column($payload['files'], 'url')
|
|
: array_column(array_column(array_slice($payload['messages'][0]['content'], 1), 'image_url'), 'url');
|
|
expectSame(array_column($duplicateFiles, 'url'), $wireUrls, 'strict ' . $protocol . ' transmits every attachment in manifest order');
|
|
expectSame(count($wireUrls), $strictResult['transmitted_file_count'], 'strict ' . $protocol . ' acknowledgment matches actual wire attachment count');
|
|
expectSame($protocol, $strictResult['attachment_transport'], 'strict response identifies the actual attachment protocol');
|
|
|
|
$upstreamTestRequests = [];
|
|
$ordinaryResult = DifyChatService::chat('qwen', [], 'offline query', 'offline-user', $duplicateFiles);
|
|
expectSame(true, $ordinaryResult['ok'], 'ordinary ' . $protocol . ' chat remains successful');
|
|
$payload = $upstreamTestRequests[0]['payload'];
|
|
$wireUrls = $protocol === 'dify' ? array_column($payload['files'], 'url')
|
|
: array_column(array_column(array_slice($payload['messages'][0]['content'], 1), 'image_url'), 'url');
|
|
expectSame(array_values(array_unique(array_column($duplicateFiles, 'url'))), $wireUrls, 'ordinary ' . $protocol . ' still deduplicates URLs');
|
|
}
|
|
foreach ([
|
|
['type' => 'image', 'url' => 'ftp://cdn.example.test/invalid.jpg'],
|
|
['type' => 'image', 'url' => 'https://user@cdn.example.test/invalid.jpg'],
|
|
['type' => 'image', 'url' => "https://cdn.example.test/invalid\n.jpg"],
|
|
['type' => 'unknown', 'url' => 'https://cdn.example.test/invalid.jpg'],
|
|
null,
|
|
] as $invalidFile) {
|
|
$upstreamTestRequests = [];
|
|
$invalidFiles = [$duplicateFiles[0], $duplicateFiles[1], $invalidFile];
|
|
$invalidResult = DifyChatService::chat('qwen', [], 'offline query', 'offline-user', $invalidFiles, ['strict_files' => true]);
|
|
expectSame('STRICT_FILES_INVALID_OR_LIMIT', $invalidResult['error_code'] ?? '', 'strict duplicate preservation never bypasses attachment validation');
|
|
expectSame([], $upstreamTestRequests, 'invalid strict batches are rejected before transport');
|
|
}
|
|
foreach ([2, 0] as $limit) {
|
|
$upstreamTestConfig['max_files'] = $limit;
|
|
$upstreamTestRequests = [];
|
|
$limitedResult = DifyChatService::chat('qwen', [], 'offline query', 'offline-user', $duplicateFiles, ['strict_files' => true]);
|
|
expectSame('STRICT_FILES_INVALID_OR_LIMIT', $limitedResult['error_code'] ?? '', 'strict limits count logical attachments even when URLs repeat');
|
|
expectSame([], $upstreamTestRequests, 'over-limit strict batches are never partially transmitted');
|
|
}
|
|
|
|
// 被截断的附件必须出现在提示词清单里,否则模型会把“没看到”当成“没有”。
|
|
$cappedSpecs = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat-messages',
|
|
'model-name',
|
|
[],
|
|
'clinical prompt',
|
|
'server-user',
|
|
false,
|
|
$capped['kept'],
|
|
$capped['dropped'],
|
|
]);
|
|
$cappedQuery = $cappedSpecs[0]['payload']['query'];
|
|
expectSame(true, str_contains($cappedQuery, '<ATTACHMENTS_NOT_INLINE>'), 'dropped attachments are declared to the model');
|
|
expectSame(true, str_contains($cappedQuery, 'https://cdn.example.test/tongue3.jpg'), 'dropped attachment URL is listed');
|
|
expectSame(false, str_contains($cappedQuery, 'https://cdn.example.test/tongue0.jpg'), 'delivered attachments are not duplicated in the manifest');
|
|
|
|
// 附件被整体拒绝时必须降级为纯文本重试,而不是让整次问诊失败。
|
|
$plan = callPrivate('buildAttemptPlan', [$capped['kept'], $capped['dropped']]);
|
|
expectSame(2, count($plan), 'a request with attachments gets a text-only fallback attempt');
|
|
expectSame([], $plan[1]['files'], 'the fallback attempt sends no attachments');
|
|
expectSame(9, count($plan[1]['omitted']), 'the fallback attempt declares every attachment');
|
|
expectSame(1, count(callPrivate('buildAttemptPlan', [[], []])), 'a request without attachments is attempted once');
|
|
|
|
$inputPlan = callPrivate('buildInputAttemptPlan', [['prompt_version' => 'v2']]);
|
|
expectSame(2, count($inputPlan), 'structured Dify inputs get one compatibility fallback');
|
|
expectSame([], $inputPlan[1], 'the compatibility fallback uses an empty inputs object');
|
|
expectSame([[]], callPrivate('buildInputAttemptPlan', [[]]), 'empty inputs are not retried twice');
|
|
|
|
$difyInputSpec = ['protocol' => 'dify'];
|
|
$openAiInputSpec = ['protocol' => 'openai'];
|
|
expectSame(
|
|
true,
|
|
callPrivate('isInputRejection', [
|
|
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'],
|
|
$difyInputSpec,
|
|
['prompt_version' => 'v2'],
|
|
]),
|
|
'Dify invalid_param retries with query-only input'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isInputRejection', [
|
|
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'],
|
|
$difyInputSpec,
|
|
[],
|
|
]),
|
|
'an already empty inputs object is never retried'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isInputRejection', [
|
|
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'],
|
|
$openAiInputSpec,
|
|
['prompt_version' => 'v2'],
|
|
]),
|
|
'OpenAI protocol does not use the Dify input fallback'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isInputRejection', [
|
|
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"provider_quota_exceeded"}'],
|
|
$difyInputSpec,
|
|
['prompt_version' => 'v2'],
|
|
]),
|
|
'quota and provider failures are not submitted twice'
|
|
);
|
|
expectSame(
|
|
true,
|
|
callPrivate('isInputRejection', [
|
|
[
|
|
'errno' => 0,
|
|
'http_code' => 200,
|
|
'upstream_error' => true,
|
|
'upstream_code' => 'invalid_param',
|
|
'emitted' => false,
|
|
],
|
|
$difyInputSpec,
|
|
['prompt_version' => 'v2'],
|
|
]),
|
|
'a streaming invalid_param before any delta also retries without inputs'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isInputRejection', [
|
|
[
|
|
'errno' => 0,
|
|
'http_code' => 200,
|
|
'upstream_error' => true,
|
|
'upstream_code' => 'invalid_param',
|
|
'emitted' => true,
|
|
],
|
|
$difyInputSpec,
|
|
['prompt_version' => 'v2'],
|
|
]),
|
|
'a stream that already emitted content is never replayed'
|
|
);
|
|
|
|
expectSame(true, callPrivate('shouldRetryWithoutFiles', [400, $capped['kept']]), 'invalid_param retries without attachments');
|
|
expectSame(true, callPrivate('shouldRetryWithoutFiles', [413, $capped['kept']]), 'oversized attachments retry without attachments');
|
|
expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-only rejection is not retried');
|
|
expectSame(false, callPrivate('shouldRetryWithoutFiles', [401, $capped['kept']]), 'a credential failure is not retried');
|
|
expectSame(false, callPrivate('shouldRetryWithoutFiles', [500, $capped['kept']]), 'an upstream outage is not retried here');
|
|
|
|
// 协议回退会把最初的“附件被拒”换成另一协议的状态码(Dify 400 -> OpenAI 404),
|
|
// 降级判断必须按每次响应累计,否则去掉附件的重试永远不会发生。
|
|
expectSame(
|
|
true,
|
|
callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 400], $capped['kept']]),
|
|
'an attachment rejection is recognised on the response that carried it'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 404], $capped['kept']]),
|
|
'the fallback protocol 404 is not itself an attachment rejection'
|
|
);
|
|
expectSame(
|
|
true,
|
|
callPrivate('isFileRejection', [
|
|
['errno' => 0, 'http_code' => 200, 'upstream_error' => true],
|
|
$capped['kept'],
|
|
]),
|
|
'a 200 stream carrying event:error counts as an attachment rejection'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isFileRejection', [
|
|
['errno' => 0, 'http_code' => 200, 'upstream_error' => true],
|
|
[],
|
|
]),
|
|
'a text-only request never degrades further'
|
|
);
|
|
expectSame(
|
|
false,
|
|
callPrivate('isFileRejection', [['errno' => 28, 'http_code' => 0], $capped['kept']]),
|
|
'a transport failure is not mistaken for an attachment rejection'
|
|
);
|
|
|
|
// Dify 的 inputs 必须是 JSON 对象。PHP 空数组会被编码成 [],上游以
|
|
// invalid_param 拒绝整单——空 inputs 的调用方会 100% 失败。
|
|
$emptyInputs = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat-messages', 'model-name', [], 'prompt', 'server-user',
|
|
]);
|
|
expectSame(
|
|
true,
|
|
str_contains((string) json_encode($emptyInputs[0]['payload']), '"inputs":{}'),
|
|
'empty inputs are encoded as a JSON object, never as an array'
|
|
);
|
|
$filledInputs = callPrivate('buildRequestSpecs', [
|
|
'https://ai.example.test/v1/chat-messages', 'model-name', ['prompt_version' => 'v2'], 'prompt', 'server-user',
|
|
]);
|
|
expectSame(
|
|
true,
|
|
str_contains((string) json_encode($filledInputs[0]['payload']), '"inputs":{"prompt_version":"v2"}'),
|
|
'populated inputs keep their keys'
|
|
);
|
|
|
|
expectSame('invalid_param', callPrivate('cleanUpstreamCode', ['invalid_param']), 'enumerable upstream codes are kept for logs');
|
|
expectSame('', callPrivate('cleanUpstreamCode', ["Run failed: 404 for https://cdn.example.test/a.pdf"]), 'upstream prose never reaches the log');
|
|
|
|
expectSame(true, callPrivate('isValidBaseUrl', ['https://ai.example.test/v1']), 'https URL');
|
|
expectSame(true, callPrivate('isValidBaseUrl', ['http://127.0.0.1:8080/v1']), 'internal http URL');
|
|
expectSame(false, callPrivate('isValidBaseUrl', ['file:///tmp/socket']), 'non-http URL');
|
|
expectSame(false, callPrivate('isValidBaseUrl', ['https://user@example.test/v1']), 'userinfo URL');
|
|
expectSame(false, callPrivate('isValidBaseUrl', ['https://ai.example.test/v1?unsafe=query']), 'query URL');
|
|
expectSame(true, callPrivate('isValidTimeout', [90]), 'normal timeout');
|
|
expectSame(false, callPrivate('isValidTimeout', [0]), 'zero timeout');
|
|
expectSame(false, callPrivate('isValidTimeout', [301]), 'excessive timeout');
|
|
|
|
echo "Prescription AI upstream contract: OK\n";
|
|
|
|
}
|