更新
This commit is contained in:
@@ -38,6 +38,48 @@ expectSame(false, str_contains($serializedSpecs, 'api_key'), 'credential field i
|
||||
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',
|
||||
@@ -48,6 +90,30 @@ $openAi = callPrivate('buildRequestSpecs', [
|
||||
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',
|
||||
@@ -73,6 +139,81 @@ expectSame(
|
||||
'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'
|
||||
);
|
||||
|
||||
// 被截断的附件必须出现在提示词清单里,否则模型会把“没看到”当成“没有”。
|
||||
$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');
|
||||
|
||||
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 的 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');
|
||||
|
||||
Reference in New Issue
Block a user