Files
zyt/server/tests/DifyChatStreamContractTest.php
T
2026-08-27 16:26:14 +08:00

248 lines
11 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
function difyStreamExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** @return mixed */
function callDifyStreamPrivate(string $method, array $arguments)
{
return (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $arguments);
}
$generic = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
['case' => 'redacted'],
'safe query',
'admin-safe',
true,
]);
difyStreamExpect(count($generic) === 2, 'generic /v1 keeps Dify then OpenAI fallback order');
difyStreamExpect($generic[0]['protocol'] === 'dify', 'Dify remains the first generic protocol');
difyStreamExpect(
$generic[0]['payload']['response_mode'] === 'streaming',
'Dify stream request uses response_mode=streaming'
);
difyStreamExpect($generic[1]['protocol'] === 'openai', 'OpenAI remains the fallback protocol');
difyStreamExpect($generic[1]['payload']['stream'] === true, 'OpenAI stream request uses stream=true');
// inputs 以对象语义上线(空 inputs 编码成 [] 会被 Dify 判为 invalid_param),
// 因此按线上实际编码结果断言,而不是按 PHP 数组比较。
difyStreamExpect(
json_decode((string) json_encode($generic[0]['payload']['inputs']), true) === ['case' => 'redacted']
&& $generic[0]['payload']['query'] === 'safe query'
&& $generic[0]['payload']['user'] === 'admin-safe',
'Dify streaming preserves structured inputs, query and user'
);
$blocking = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
[],
'safe query',
'admin-safe',
]);
difyStreamExpect(
$blocking[0]['payload']['response_mode'] === 'blocking',
'legacy Dify blocking request remains unchanged'
);
difyStreamExpect(
!array_key_exists('stream', $blocking[1]['payload']),
'legacy OpenAI blocking request does not gain a stream field'
);
$withFiles = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
['case' => 'full-context'],
'analyze all supplied records',
'admin-safe',
false,
[
['type' => 'image', 'transfer_method' => 'remote_url', 'url' => 'https://files.example.test/tongue.jpg'],
['type' => 'document', 'transfer_method' => 'remote_url', 'url' => 'https://files.example.test/report.pdf'],
],
]);
// 回退协议保留(网关 404/405 时才会用到),但任何附件都不得被静默丢弃:
// Dify 走文件通道,OpenAI-compatible 无法内联的附件必须落在提示词清单里。
difyStreamExpect(count($withFiles) === 2, 'ambiguous /v1 keeps the OpenAI fallback reachable');
difyStreamExpect($withFiles[0]['protocol'] === 'dify', 'Dify remains the preferred protocol');
difyStreamExpect(count($withFiles[0]['payload']['files']) === 2, 'Dify receives every supplied clinical file');
difyStreamExpect($withFiles[0]['payload']['files'][0]['type'] === 'image', 'tongue image keeps image type');
difyStreamExpect($withFiles[0]['payload']['files'][1]['type'] === 'document', 'report keeps document type');
$fallbackText = $withFiles[1]['payload']['messages'][0]['content'][0]['text'];
difyStreamExpect(
str_contains($fallbackText, 'https://files.example.test/report.pdf'),
'the fallback protocol declares the report it cannot inline'
);
$explicitDify = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1/chat-messages', 'model-safe', [], 'query', 'user', true,
]);
$explicitOpenAi = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1/chat/completions', 'model-safe', [], 'query', 'user', true,
]);
difyStreamExpect(count($explicitDify) === 1 && $explicitDify[0]['protocol'] === 'dify', 'explicit Dify endpoint never changes protocol');
difyStreamExpect(count($explicitOpenAi) === 1 && $explicitOpenAi[0]['protocol'] === 'openai', 'explicit OpenAI endpoint never changes protocol');
$progressOption = callDifyStreamPrivate('curlProgressOption', []);
$expectedProgressOption = null;
foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $optionName) {
if (defined($optionName)) {
$expectedProgressOption = (int) constant($optionName);
break;
}
}
difyStreamExpect(
$progressOption === $expectedProgressOption,
'stream cancellation selects the newest cURL progress callback available at runtime'
);
$serviceSource = file_get_contents(dirname(__DIR__) . '/app/common/service/DifyChatService.php');
difyStreamExpect(
is_string($serviceSource)
&& str_contains($serviceSource, "'CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'"),
'stream cancellation retains the libcurl 7.29 progress callback fallback'
);
$retryableStream = [
'errno' => 0,
'http_code' => 200,
'emitted' => false,
'upstream_error' => true,
'finished' => false,
];
difyStreamExpect(
callDifyStreamPrivate('shouldTryNextProtocol', [$retryableStream, true]) === true,
'a terminal-free stream error before any delta tries the alternate protocol'
);
$retryableStream['emitted'] = true;
difyStreamExpect(
callDifyStreamPrivate('shouldTryNextProtocol', [$retryableStream, true]) === false,
'an emitted delta forbids protocol retry'
);
difyStreamExpect(
callDifyStreamPrivate('shouldTryNextProtocol', [[
'errno' => 0,
'http_code' => 400,
], false]) === false,
'business input rejection is not hidden by an alternate protocol attempt'
);
difyStreamExpect(
callDifyStreamPrivate('shouldTryNextProtocol', [[
'errno' => 0,
'http_code' => 404,
], false]) === true,
'an unavailable path tries the alternate protocol'
);
difyStreamExpect(
callDifyStreamPrivate('shouldTryNextProtocol', [[
'errno' => 0,
'http_code' => 401,
], true]) === false,
'credential failures are not hidden by protocol retry'
);
$difyWire = ": ping\r\n\r\n"
. "data: {\"event\":\"message\",\"answer\":\"\",\"message_id\":\"msg-safe\"}\r\n\r\n"
. "data: {\"event\":\"agent_message\",\"answer\":\"\"}\r\n\r\n"
. "data: {\"event\":\"ping\"}\r\n\r\n"
. "data: {\"event\":\"message_end\",\"message_id\":\"msg-safe\"}\r\n\r\n";
$difyChunks = str_split($difyWire, 1);
$decodedDify = callDifyStreamPrivate('decodeStreamChunks', ['dify', $difyChunks]);
difyStreamExpect($decodedDify['content'] === '你好', 'Dify decoder handles every possible byte boundary, including UTF-8 bytes');
difyStreamExpect($decodedDify['deltas'] === ['你', '好'], 'Dify decoder emits only message text');
difyStreamExpect($decodedDify['message_id'] === 'msg-safe', 'Dify decoder retains the safe message id internally');
difyStreamExpect($decodedDify['finished'] === true, 'Dify message_end terminates parsing');
$openAiWire = "data: {\"id\":\"chat-safe\",\"choices\":[{\"delta\":{\"content\":\"A\"}}]}\n\n"
. "data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n"
. "data: [DONE]";
$decodedOpenAi = callDifyStreamPrivate('decodeStreamChunks', ['openai', str_split($openAiWire, 2)]);
difyStreamExpect($decodedOpenAi['content'] === 'A中', 'OpenAI decoder handles arbitrary byte chunks and final frame without newline');
difyStreamExpect($decodedOpenAi['deltas'] === ['A', '中'], 'OpenAI decoder emits choices delta content only');
difyStreamExpect($decodedOpenAi['finished'] === true, 'OpenAI [DONE] terminates parsing');
$malformed = callDifyStreamPrivate('decodeStreamChunks', [
'dify',
["data: not-json\n\n", "data: {\"event\":\"error\",\"message\":\"secret-upstream-body\"}\n\n"],
]);
difyStreamExpect($malformed['content'] === '', 'malformed and upstream error frames never become text');
difyStreamExpect($malformed['upstream_error'] === true, 'Dify error frame becomes an internal error flag');
difyStreamExpect(!str_contains(json_encode($malformed), 'secret-upstream-body'), 'upstream error body is not retained');
$safeError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => 0,
'http_code' => 200,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => true,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
$encodedError = json_encode($safeError, JSON_UNESCAPED_UNICODE);
difyStreamExpect($safeError['error_code'] === 'UPSTREAM_REJECTED', 'upstream SSE errors map to a stable internal code');
difyStreamExpect(!str_contains($encodedError, 'secret'), 'formatted stream errors contain no upstream body, key or prompt');
$serviceSource = file_get_contents(dirname(__DIR__) . '/app/common/service/DifyChatService.php');
difyStreamExpect(
is_string($serviceSource)
&& str_contains($serviceSource, '$responseCode < 200 || $responseCode >= 300')
&& str_contains($serviceSource, 'CURLOPT_HEADERFUNCTION => $header')
&& str_contains($serviceSource, "'Accept: text/event-stream'")
&& !str_contains($serviceSource, "config('ai')"),
'streaming rejects HTTP error bodies before parsing and never mixes daily-diet AI configuration'
);
$timeoutError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => CURLE_OPERATION_TIMEDOUT,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
$disconnectError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => CURLE_ABORTED_BY_CALLBACK,
'http_code' => 200,
'content' => 'partial prompt must not appear',
'message_id' => '',
'emitted' => true,
'upstream_error' => false,
'client_aborted' => true,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
difyStreamExpect($timeoutError['error_code'] === 'UPSTREAM_TIMEOUT', 'curl timeout maps to a stable timeout result');
difyStreamExpect($disconnectError['error_code'] === 'CLIENT_DISCONNECTED', 'client abort takes precedence over curl abort errno');
difyStreamExpect(!str_contains(json_encode($disconnectError), 'partial prompt'), 'disconnect result does not echo partial content');
$incompleteError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => 0,
'http_code' => 200,
'content' => 'partial answer',
'message_id' => '',
'emitted' => true,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
difyStreamExpect($incompleteError['error_code'] === 'INCOMPLETE_RESPONSE', 'missing [DONE]/message_end cannot become a successful done');
difyStreamExpect(!str_contains(json_encode($incompleteError), 'partial answer'), 'incomplete response error does not echo partial content');
echo "Dify chat stream contract: OK\n";