'synthetic']; } } function config(string $name): array { return $GLOBALS['classificationConfig']; } function curl_init(): \stdClass { return (object) ['errno' => 0]; } function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; $handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true); return true; } function curl_exec(\stdClass $handle): string|bool { $options = $handle->options; $payload = $handle->payload; $streaming = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); $GLOBALS['classificationRequests'][] = [ 'mode' => $streaming ? 'streaming' : 'blocking', 'payload' => $payload, 'raw' => $options[CURLOPT_POSTFIELDS], 'timeout' => $options[CURLOPT_TIMEOUT], ]; $fixture = array_shift($GLOBALS['classificationFixtures']); if (!is_array($fixture)) throw new \RuntimeException('Unexpected extra upstream request'); $handle->code = $fixture['http'] ?? 200; $handle->errno = $fixture['errno'] ?? 0; if (!empty($fixture['delay_us'])) usleep($fixture['delay_us']); if (!empty($fixture['cancel'])) $GLOBALS['classificationCancelled'] = true; if (isset($options[CURLOPT_HEADERFUNCTION])) { $options[CURLOPT_HEADERFUNCTION]($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); } $progress = $options[defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION] ?? null; if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) { $handle->errno = CURLE_ABORTED_BY_CALLBACK; return false; } if (isset($fixture['body'])) { $body = $fixture['body']; } elseif (isset($fixture['error'])) { $body = $streaming && $handle->code === 200 ? 'data: ' . json_encode(['event' => 'error'] + $fixture['error']) . "\n\n" : json_encode($fixture['error']); } else { $body = !$streaming ? '{"answer":"synthetic answer"}' : (isset($payload['messages']) ? "data: {\"choices\":[{\"delta\":{\"content\":\"synthetic answer\"}}]}\n\ndata: [DONE]\n\n" : "data: {\"event\":\"message\",\"answer\":\"synthetic answer\"}\n\ndata: {\"event\":\"message_end\"}\n\n"); } if (!$streaming) return $body; // Exercise bounded HTTP error parsing across arbitrary network chunks. $write = $options[CURLOPT_WRITEFUNCTION]; foreach (str_split($body, 11) as $chunk) { if ($write($handle, $chunk) !== strlen($chunk)) { $handle->errno = CURLE_WRITE_ERROR; break; } } return true; } function curl_errno(\stdClass $handle): int { return $handle->errno; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; } function curl_close(\stdClass $handle): void { ++$GLOBALS['classificationClosed']; } } namespace { $root = dirname(__DIR__, 2); require $root . '/server/app/common/logic/BaseLogic.php'; require $root . '/server/app/common/service/DifyChatService.php'; require $root . '/server/app/common/service/DifyStreamTiming.php'; require $root . '/server/app/adminapi/service/AssistantSseTiming.php'; require $root . '/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php'; use app\common\service\DifyChatService; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AssistantSseTiming; function classificationExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); } $classificationConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, 'max_files' => 3, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-only']]]; $files = array_map(static fn (int $id): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $id . '.png'], [1, 2]); $sections = array_map(static fn (int $id): array => ['key' => 'source_' . $id, 'label' => 'synthetic', 'count' => $id === 13 ? 0 : 1, 'availability' => $id === 13 ? 'empty' : 'present'], range(1, 13)); // Test the one-attempt classification boundary in isolation. Public-entry // clinical batching/recovery is covered by DifyClinicalAttachmentBatchTest. $options = ['required_files' => true, '_required_batch_internal' => true, 'knowledge_mode' => 'evidence', 'required_sections' => $sections]; $reset = static function (array $fixtures): void { $GLOBALS['classificationFixtures'] = $fixtures; $GLOBALS['classificationRequests'] = []; $GLOBALS['classificationLogs'] = []; $GLOBALS['classificationCancelled'] = false; $GLOBALS['classificationClosed'] = 0; $GLOBALS['classificationThrowLog'] = false; }; $call = static function (bool $streaming, array $inputs = [], array $extra = []) use ($files, $options): array { $selected = array_replace($options, $extra); return $streaming ? DifyChatService::streamChat('qwen', $inputs, 'synthetic query', 'synthetic-user', static fn (): bool => true, null, $files, $selected) : DifyChatService::chat('qwen', $inputs, 'synthetic query', 'synthetic-user', $files, $selected); }; $private = static fn (string $method, array $args) => (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $args); $inputError = ['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Unknown input variable: synthetic_field']]; $fileError = ['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Number of files exceeds the limit.']]; $secret = 'SYNTHETIC_PRIVATE_MARKER'; foreach ([false, true] as $streaming) { // Mere status codes/invalid_param do not identify a file or an inputs error. foreach ([400, 413, 415, 422] as $http) { $reset([['http' => $http, 'error' => ['code' => 'invalid_param', 'message' => $secret . ' https://synthetic.invalid/private.png']]]); $result = $call($streaming, ['synthetic_field' => 'value']); classificationExpect($result['error_code'] === 'UPSTREAM_REJECTED' && count($classificationRequests) === 1, 'Generic validation does not invent attachment or inputs rejection'); classificationExpect(!str_contains(json_encode([$result, $classificationLogs]), $secret), 'Provider messages never enter errors or logs'); classificationExpect(count($classificationLogs) === 1 && str_contains($classificationLogs[0], '"upstream_code":"invalid_param"') && !str_contains($classificationLogs[0], 'https://'), 'Safe failure code is logged immediately without hostnames or resource URLs'); } foreach ([[401, 'CONFIG_INVALID'], [403, 'CONFIG_INVALID'], [429, 'UPSTREAM_BUSY'], [503, 'UPSTREAM_BUSY']] as [$http, $expected]) { $reset([$inputError, ['http' => $http, 'error' => ['code' => 'invalid_param', 'message' => 'file type not supported']]]); $result = $call($streaming, ['synthetic_field' => 'value']); classificationExpect($result['error_code'] === $expected && count($classificationRequests) === 2, 'Latest auth/availability response is not overwritten by an earlier inputs rejection'); classificationExpect(array_map(static fn (array $request): int => count($request['payload']['files']), $classificationRequests) === [2, 2], 'Every compatibility request retains both mandatory files'); } $reset([$inputError, []]); $result = $call($streaming, ['synthetic_field' => 'value']); classificationExpect(!empty($result['ok']) && count($classificationRequests) === 2 && $classificationRequests[1]['payload']['inputs'] === [] && str_contains($classificationRequests[1]['raw'], '"inputs":{}') && $classificationRequests[0]['payload']['query'] === $classificationRequests[1]['payload']['query'], 'Explicit inputs compatibility succeeds with identical query/files and object inputs'); foreach ([$fileError, ['http' => 413, 'error' => ['code' => 'file_too_large']], ['http' => 415, 'error' => ['code' => 'unsupported_file_type']], ['http' => 422, 'error' => ['code' => 'invalid_param', 'message' => 'File type is not supported.']]] as $fixture) { $reset([$fixture]); $events = []; $result = $call($streaming, ['synthetic_field' => 'value'], ['on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]); classificationExpect($result['error_code'] === 'REQUIRED_FILES_REJECTED' && count($classificationRequests) === 1, 'Affirmative file count/type/size failure never strips mandatory files or retries inputs'); $last = $events[count($events) - 1]; foreach ([$result, $last] as $manifest) { classificationExpect($manifest['transfer_manifest_final'] === true && $manifest['attachment_count'] === 2 && count($manifest['required_sections']) === 13 && array_unique(array_column($manifest['items'], 'status')) === ['failed'] && array_unique(array_column($manifest['required_sections'], 'status')) === ['failed'] && $manifest['required_sections'][12]['availability'] === 'empty', 'Failure results and progress carry a complete final failed manifest'); } } } // Business SSE errors may be reported under HTTP 200. An ambiguous base must // not re-submit quotas, credentials, cancellation or affirmative file failures. $classificationConfig['base_url'] = 'https://synthetic.invalid/v1'; foreach ([['provider_quota_exceeded', 'UPSTREAM_BUSY'], ['invalid_api_key', 'CONFIG_INVALID'], ['provider_unavailable', 'UPSTREAM_BUSY'], ['task_cancelled', 'AI_ASSISTANT_CANCELLED'], ['file_download_failed', 'REQUIRED_FILES_REJECTED']] as [$code, $expected]) { $reset([['http' => 200, 'error' => ['code' => $code, 'message' => $secret]]]); $result = $call(true); classificationExpect($result['error_code'] === $expected && count($classificationRequests) === 1, 'SSE business failure retains its diagnosis without cross-protocol replay'); } $classificationConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages'; $reset([['http' => 200, 'error' => ['code' => 'unknown_provider_error', 'message' => $secret]]]); classificationExpect($call(true)['error_code'] === 'UPSTREAM_REJECTED', 'Unknown SSE errors do not implicate files'); $modeError = ['http' => 400, 'delay_us' => 3000, 'error' => ['code' => 'invalid_param', 'message' => 'response_mode must be blocking. ' . $secret]]; $prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => 'synthetic query', 'user' => 'synthetic-user', 'files' => $files, 'inputs' => ['synthetic_field' => 'value'], 'required_sections' => $sections, 'upstream_timeout' => 5]; $reset([$modeError, []]); $answer = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, null, new AssistantSseTiming(), ['knowledge_mode' => 'evidence']); classificationExpect(is_array($answer) && array_column($classificationRequests, 'mode') === ['streaming', 'blocking'] && count($classificationRequests[1]['payload']['files']) === 2 && $classificationRequests[0]['payload']['files'] === $classificationRequests[1]['payload']['files'] && $classificationRequests[0]['payload']['inputs'] === $classificationRequests[1]['payload']['inputs'] && $classificationRequests[1]['timeout'] <= 4, 'Actual assistant recovers response_mode with both files and the remaining shared budget'); // An emitted clinical delta prevents replay even if a later SSE error names inputs. $reset([['http' => 200, 'body' => "data: {\"event\":\"message\",\"answer\":\"synthetic partial\"}\n\ndata: {\"event\":\"error\",\"code\":\"invalid_inputs\"}\n\n"]]); $answer = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, null, new AssistantSseTiming(), ['knowledge_mode' => 'evidence']); classificationExpect($answer === null && count($classificationRequests) === 1, 'No inputs or blocking replay after delivered text'); $reset([['http' => 400, 'cancel' => true, 'error' => ['code' => 'file_too_large']]]); $result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, $options + ['should_abort' => static fn (): bool => $GLOBALS['classificationCancelled']]); classificationExpect($result['error_code'] === 'AI_ASSISTANT_CANCELLED' && count($classificationRequests) === 1 && $classificationClosed === 1, 'Cancellation closes transport and wins over apparent file errors'); // Optional-file and generic strict callers retain their existing distinction. $reset([['http' => 400], []]); $result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, ['knowledge_mode' => 'evidence']); classificationExpect(!empty($result['ok']) && !isset($classificationRequests[1]['payload']['files']), 'Legacy optional files keep the explicit text fallback'); $reset([['http' => 400]]); $result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, ['knowledge_mode' => 'evidence', 'strict_files' => true]); classificationExpect($result['error_code'] === 'UPSTREAM_REJECTED' && count($classificationRequests) === 1, 'Legacy strict never drops mandatory batch data'); $reset([['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Failed to download image ' . $secret]]]); $transport = $private('sendStreamRequest', ['dify', 'https://synthetic.invalid/v1/chat-messages', ['response_mode' => 'streaming'], 'synthetic-only', 5, static fn (): bool => true, null]); classificationExpect($transport['upstream_code'] === 'invalid_param' && $transport['upstream_reason'] === 'file_access' && !isset($transport['body']) && !str_contains(json_encode($transport), $secret), 'Split HTTP error JSON retains only a short code and fixed reason'); $reset([['http' => 400, 'error' => ['code' => 'file_too_large', 'message' => str_repeat($secret, 1500)]]]); $transport = $private('sendStreamRequest', ['dify', 'https://synthetic.invalid/v1/chat-messages', ['response_mode' => 'streaming'], 'synthetic-only', 5, static fn (): bool => true, null]); classificationExpect($transport['upstream_code'] === '' && $transport['upstream_reason'] === '' && !isset($transport['body']), 'Oversized error bodies are discarded instead of retained or partially decoded'); classificationExpect($private('upstreamErrorMetadata', [['error' => ['code' => 'insufficient_quota', 'message' => $secret]]])['upstream_reason'] === 'quota', 'Nested OpenAI JSON errors use the same safe classification'); $reset([['http' => 503]]); $classificationThrowLog = true; classificationExpect($call(true)['error_code'] === 'UPSTREAM_BUSY', 'Logging failure cannot alter or throw away the upstream diagnosis'); echo "Dify mandatory-file error classification / compatibility / final manifests / cancellation: OK\n"; }