options = $options; $handle->url = $options[CURLOPT_URL]; $handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true); return true; } function curl_exec(\stdClass $handle) { $payload = $handle->payload; $streaming = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); $GLOBALS['integrationRequests'][] = ['url' => $handle->url, 'payload' => $payload, 'streaming' => $streaming]; $scenario = $GLOBALS['integrationScenario']; if (($scenario === 'blocking_only' || $scenario === 'cancel_blocking') && $streaming) $handle->code = 400; elseif ($scenario === 'file_rejected' && !empty($payload['files'])) $handle->code = 400; elseif ($scenario === 'protocol_fallback' && str_ends_with($handle->url, '/chat-messages')) $handle->code = 404; else $handle->code = 200; if (isset($handle->options[CURLOPT_HEADERFUNCTION])) { ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); } if ($handle->code !== 200) return $streaming ? true : '{}'; $progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION; $progress = $handle->options[$progressOption] ?? null; if (!$streaming) { if ($scenario === 'cancel_blocking') $GLOBALS['integrationCancelled'] = true; if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false; return json_encode(['answer' => '合成完整回答']); } if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false; $write = $handle->options[CURLOPT_WRITEFUNCTION]; if (str_ends_with($handle->url, '/chat-messages')) { $write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => '合成完整回答']) . "\n\n"); $write($handle, "data: {\"event\":\"message_end\"}\n\n"); } else { $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => '合成完整回答']]]]) . "\n\n"); $write($handle, "data: [DONE]\n\n"); } return true; } function curl_errno(\stdClass $handle): int { return 0; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; } function curl_close(\stdClass $handle): void { ++$GLOBALS['integrationClosed']; } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\common\service\DifyChatService; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AssistantSseTiming; function integrationExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } $integrationConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, 'max_files' => 2, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; $reset = static function (string $scenario = 'success'): void { $GLOBALS['integrationScenario'] = $scenario; $GLOBALS['integrationRequests'] = []; $GLOBALS['integrationCancelled'] = false; $GLOBALS['integrationClosed'] = 0; }; $abort = static fn (): bool => $GLOBALS['integrationCancelled']; $strict = ['strict_files' => true, 'knowledge_mode' => 'evidence', 'should_abort' => $abort]; $images = array_map(static fn (int $id): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $id . '.png'], [1, 2, 3]); $document = [['type' => 'document', 'url' => 'https://synthetic.invalid/report.pdf']]; $call = static fn (array $files): array => DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $strict); $reset(); integrationExpect($call($images)['error_code'] === 'STRICT_FILES_INVALID_OR_LIMIT' && $integrationRequests === [], 'strict + abort rejects over-limit files before transport'); integrationExpect($call([['type' => 'image', 'url' => 'invalid']])['error_code'] === 'STRICT_FILES_INVALID_OR_LIMIT' && $integrationRequests === [], 'strict + abort rejects invalid files before transport'); $result = $call([$images[0], $images[0]]); integrationExpect(!empty($result['ok']) && $result['transmitted_file_count'] === 2 && $result['attachment_transport'] === 'dify' && count($integrationRequests[0]['payload']['files']) === 2, 'strict stream retains logical duplicates and exact success metadata'); $reset('file_rejected'); $result = $call([$images[0]]); integrationExpect(empty($result['ok']) && count($integrationRequests) === 1 && count($integrationRequests[0]['payload']['files']) === 1, 'strict rejected files never trigger a text-only success fallback'); $integrationConfig['base_url'] = 'https://synthetic.invalid/v1/chat/completions'; $reset(); integrationExpect($call($document)['error_code'] === 'FILE_TYPE_UNSUPPORTED' && $integrationRequests === [], 'strict documents cannot be omitted by OpenAI transport'); $result = $call([$images[0]]); integrationExpect(!empty($result['ok']) && $result['transmitted_file_count'] === 1 && $result['attachment_transport'] === 'openai', 'strict supported OpenAI images retain success metadata'); $integrationConfig['base_url'] = 'https://synthetic.invalid/v1'; $reset('protocol_fallback'); integrationExpect($call($document)['error_code'] === 'FILE_TYPE_UNSUPPORTED' && count($integrationRequests) === 1, 'strict protocol fallback fails explicitly before an unsupported document request'); $integrationConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages'; $prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => '合成资料', 'inputs' => [], 'user' => 'synthetic-user', 'upstream_timeout' => 5]; $runAssistant = static function () use ($prepared, $abort): array { $stops = []; $ends = 0; $deltas = []; $progress = []; $result = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; }, $abort, new AssistantSseTiming(), [ 'knowledge_mode' => 'evidence', 'on_stop' => static function (bool $accepted) use (&$stops): void { $stops[] = $accepted; }, 'on_transfer_end' => static function () use (&$ends): void { ++$ends; }, 'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; }, ]); return compact('result', 'stops', 'ends', 'deltas', 'progress'); }; $reset('blocking_only'); $test = $runAssistant(); integrationExpect(is_array($test['result']) && $test['result']['answer'] === '合成完整回答', 'actual blocking-only gateway compatibility succeeds'); integrationExpect(array_column($integrationRequests, 'streaming') === [true, false] && array_column(array_column($integrationRequests, 'payload'), 'response_mode') === ['streaming', 'blocking'], 'compatibility sends streaming once then genuine blocking once'); integrationExpect($test['ends'] === 2 && $test['stops'] === [], 'completed compatibility transfer does not invent stop acceptance'); $reset('cancel_blocking'); $test = $runAssistant(); integrationExpect($test['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancellation during real blocking compatibility terminates safely'); integrationExpect(array_column($integrationRequests, 'streaming') === [true, false] && $integrationClosed === 2 && $test['ends'] === 2 && $test['stops'] === [false] && $test['deltas'] === [], 'cancel closes blocking curl, leaves remote stop unconfirmed and sends no retry or late text'); echo "Diagnosis AI strict files + cancellation / actual blocking compatibility integration: OK\n"; }