84 lines
5.9 KiB
PHP
84 lines
5.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace think\facade { class Log { public static function warning($m): void {} } }
|
|
namespace app\common\service {
|
|
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
|
|
function config(string $name): array { return $GLOBALS['reuseRecoveryConfig']; }
|
|
class DifyRequiredAttachmentUpload {
|
|
public static function prepare($endpoint, $key, $user, $files, $timeout, $abort, $options): array {
|
|
$GLOBALS['reuseRecoveryUploads'][] = $options['reuse_uploaded_files'];
|
|
$reused = $options['reuse_uploaded_files'];
|
|
return ['ok' => true, 'files' => array_map(static fn($f) => ['type' => $f['type'],
|
|
'transfer_method' => 'local_file', 'upload_file_id' => $reused ? 'stale-fixture' : 'fresh-fixture'], $files),
|
|
'uploaded_indices' => range(1, count($files)), 'skipped' => [],
|
|
'reuse_tokens' => [str_repeat('a', 64)], 'reuse_hit_count' => $reused ? count($files) : 0,
|
|
'reuse_miss_count' => $reused ? 0 : count($files),
|
|
'timings' => ['source_ms' => 1, 'upload_ms' => 2, 'ocr_ms' => 3, 'reuse_wait_ms' => 4]];
|
|
}
|
|
public static function invalidateReuse($tokens, $options): void { ++$GLOBALS['reuseRecoveryInvalidations']; }
|
|
}
|
|
function curl_init(): \stdClass { return new \stdClass(); }
|
|
function curl_setopt_array($h, array $o): bool { $h->options = $o; return true; }
|
|
function curl_exec($h) {
|
|
$o = $h->options; $payload = json_decode($o[CURLOPT_POSTFIELDS], true);
|
|
$GLOBALS['reuseRecoveryRequests'][] = $payload;
|
|
$stream = ($payload['response_mode'] ?? '') === 'streaming';
|
|
$first = count($GLOBALS['reuseRecoveryRequests']) === 1;
|
|
$scenario = $GLOBALS['reuseRecoveryScenario'] ?? 'stale';
|
|
$h->code = $first ? 400 : 200; $h->errno = 0;
|
|
if ($scenario === 'ambiguous') { $h->code = 503; $h->errno = CURLE_OPERATION_TIMEDOUT; }
|
|
if ($scenario === 'partial') $h->code = 200;
|
|
if (isset($o[CURLOPT_HEADERFUNCTION])) ($o[CURLOPT_HEADERFUNCTION])($h, 'HTTP/1.1 ' . $h->code . " Synthetic\r\n");
|
|
$body = $first ? json_encode(['code' => 'file_not_found', 'message' => 'synthetic uploaded file not found'])
|
|
: ($stream ? "data: {\"event\":\"message\",\"answer\":\"synthetic complete reply\"}\n\ndata: {\"event\":\"message_end\"}\n\n"
|
|
: '{"answer":"synthetic complete reply"}');
|
|
if ($scenario === 'partial') $body = "data: {\"event\":\"message\",\"answer\":\"already displayed\"}\n\n"
|
|
. "data: {\"event\":\"error\",\"code\":\"file_not_found\",\"message\":\"file not found\"}\n\n";
|
|
if ($stream) { ($o[CURLOPT_WRITEFUNCTION])($h, $body); return true; }
|
|
return $body;
|
|
}
|
|
function curl_errno($h): int { return $h->errno; }
|
|
function curl_getinfo($h, $option): int { return $h->code; }
|
|
function curl_close($h): void {}
|
|
}
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
use app\common\service\DifyChatService;
|
|
$reuseRecoveryConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages',
|
|
'timeout' => 5, 'max_files' => 20, 'required_attachment_transport' => 'upload',
|
|
'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-only']]];
|
|
foreach ([false, true] as $stream) {
|
|
$reuseRecoveryScenario = 'stale';
|
|
$reuseRecoveryUploads = []; $reuseRecoveryRequests = []; $reuseRecoveryInvalidations = 0; $timings = [];
|
|
$files = [['type' => 'image', 'url' => 'https://synthetic.invalid/one.png'],
|
|
['type' => 'image', 'url' => 'https://synthetic.invalid/two.png']];
|
|
$opts = ['required_files' => true, 'knowledge_mode' => 'evidence', 'request_deadline_ns' => hrtime(true) + 5000000000,
|
|
'on_attachment_timing' => static function ($m) use (&$timings): void { $timings[] = $m; }];
|
|
$deltas = [];
|
|
$result = $stream ? DifyChatService::streamChat('qwen', [], 'exact original question and full source', 'same-user',
|
|
static function ($s) use (&$deltas): void { $deltas[] = $s; }, null, $files, $opts)
|
|
: DifyChatService::chat('qwen', [], 'exact original question and full source', 'same-user', $files, $opts);
|
|
if (empty($result['ok']) || $reuseRecoveryUploads !== [true, false] || $reuseRecoveryInvalidations !== 1
|
|
|| count($reuseRecoveryRequests) !== 2 || count($timings) !== 2) throw new RuntimeException('Stale reused handle is reuploaded exactly once');
|
|
foreach ($reuseRecoveryRequests as $payload) {
|
|
if (count($payload['files']) !== 2 || $payload['user'] !== 'same-user'
|
|
|| !str_contains($payload['query'], 'exact original question and full source')) throw new RuntimeException('Fresh retry preserves complete originals and user');
|
|
}
|
|
if (($result['submitted_attachment_count'] ?? 0) !== 2 || str_contains(json_encode($result), str_repeat('a', 64))) {
|
|
throw new RuntimeException('Complete public receipt never leaks private reuse tokens');
|
|
}
|
|
}
|
|
foreach (['ambiguous', 'partial', 'cleanup_pending'] as $reuseRecoveryScenario) {
|
|
$reuseRecoveryUploads = []; $reuseRecoveryRequests = []; $reuseRecoveryInvalidations = 0;
|
|
$opts['can_start_attempt'] = static fn(): bool => $GLOBALS['reuseRecoveryScenario'] !== 'cleanup_pending'
|
|
|| $GLOBALS['reuseRecoveryRequests'] === [];
|
|
$result = DifyChatService::streamChat('qwen', [], 'exact original question and full source', 'same-user',
|
|
static fn(string $s): bool => true, null, $files, $opts);
|
|
if (count($reuseRecoveryRequests) !== 1 || count($reuseRecoveryUploads) !== 1 || $reuseRecoveryInvalidations !== 0) {
|
|
throw new RuntimeException('Ambiguous failure, displayed text, or pending cleanup must not reupload/restart');
|
|
}
|
|
}
|
|
echo "Stale uploaded handle recovery / one fresh original upload / complete source / private metadata: OK\n";
|
|
}
|