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'); $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";