microtime(true), 'deadline' => $deadline, 'indices' => array_column($images, 'index')]; $scenario = $GLOBALS['ocrBudgetScenario']; if ($scenario === 'exhaust') $GLOBALS['ocrBudgetClock'] = $deadline + 0.8; elseif ($scenario === 'cancel') { $GLOBALS['ocrBudgetClock'] += 0.1; $GLOBALS['ocrBudgetCancelled'] = true; } else $GLOBALS['ocrBudgetClock'] += min(1.25, max(0.0, $deadline - microtime(true))); if ($scenario === 'throw') throw new \RuntimeException('PRIVATE_SYNTHETIC_OCR_FAILURE'); return ['status' => 'unavailable', 'images' => []]; } } function curl_init(): \stdClass { return (object) ['errno' => 0, 'code' => 200]; } function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; } function curl_exec(\stdClass $handle): string|bool { $options = $handle->options; if (is_array($options[CURLOPT_POSTFIELDS])) { if (!str_ends_with($options[CURLOPT_URL], '/files/upload')) throw new \RuntimeException('only synthetic upload endpoint'); $GLOBALS['ocrBudgetUploads']++; $GLOBALS['ocrBudgetClock'] += 0.2; $body = json_encode(['id' => 'synthetic-upload-' . $GLOBALS['ocrBudgetUploads']]); ($options[CURLOPT_WRITEFUNCTION])($handle, $body); return $body; } $payload = json_decode($options[CURLOPT_POSTFIELDS], true); $GLOBALS['ocrBudgetModels'][] = ['payload' => $payload, 'at' => microtime(true), 'timeout_ms' => $options[CURLOPT_TIMEOUT_MS] ?? $options[CURLOPT_TIMEOUT] * 1000]; if ($GLOBALS['ocrBudgetCancelled']) throw new \RuntimeException('model must not start after cancellation'); $GLOBALS['ocrBudgetClock'] += 0.1; if (isset($options[CURLOPT_HEADERFUNCTION])) ($options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 Synthetic\r\n"); if (($payload['response_mode'] ?? '') === 'streaming') { ($options[CURLOPT_WRITEFUNCTION])($handle, "data: {\"event\":\"message\",\"answer\":\"SYNTHETIC_VISUAL_ANSWER\"}\n\ndata: {\"event\":\"message_end\"}\n\n"); return true; } return '{"answer":"SYNTHETIC_VISUAL_ANSWER"}'; } function curl_errno(\stdClass $handle): int { return $handle->errno; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; } function curl_close(\stdClass $handle): void {} } namespace { require dirname(__DIR__) . '/app/common/service/DifyRequiredAttachmentUpload.php'; require_once dirname(__DIR__) . '/app/common/service/DifyReasoningTextFilter.php'; require dirname(__DIR__) . '/app/common/service/DifyChatService.php'; use app\common\service\DifyChatService; use app\common\service\DifyRequiredAttachmentUpload; function budgetExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); } function budgetReset(string $scenario): void { $GLOBALS['ocrBudgetScenario'] = $scenario; $GLOBALS['ocrBudgetClock'] = 1000.0; $GLOBALS['ocrBudgetReads'] = []; $GLOBALS['ocrBudgetModels'] = []; $GLOBALS['ocrBudgetUploads'] = 0; $GLOBALS['ocrBudgetCancelled'] = false; } $root = sys_get_temp_dir() . '/ocr-budget-test-' . bin2hex(random_bytes(8)); $public = $root . '/public'; $temporary = $root . '/private'; mkdir($public . '/uploads', 0700, true); mkdir($temporary, 0700); $source = $public . '/uploads/synthetic.png'; file_put_contents($source, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=')); $files = [['type' => 'image', 'url' => 'https://app.synthetic.test/uploads/synthetic.png']]; $uploadOptions = ['public_root' => $public, 'temp_root' => $temporary, 'reuse_uploaded_files' => false, 'local_origin' => 'https://app.synthetic.test', 'ocr' => ['enabled' => true, 'timeout_seconds' => 60]]; $ocrBudgetConfig = ['enable' => true, 'base_url' => 'https://model.synthetic.test/v1/chat-messages', 'timeout' => 20, 'required_attachment_transport' => 'upload', 'required_attachment_upload' => $uploadOptions, 'required_attachment_ocr' => $uploadOptions['ocr'], 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'SYNTHETIC_ONLY_KEY', 'max_files' => 10]]]; $cases = 0; try { foreach ([false, true] as $streaming) { budgetReset('exhaust'); $result = $streaming ? DifyChatService::streamChat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', static fn (): bool => true, null, $files, ['required_files' => true]) : DifyChatService::chat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', $files, ['required_files' => true]); budgetExpect(!empty($result['ok']) && count($ocrBudgetReads) === 1 && count($ocrBudgetModels) === 1, 'OCR reaching its child deadline plus cleanup must still permit the original visual model request'); budgetExpect(1020.0 - $ocrBudgetModels[0]['at'] >= 8.0 - 0.00001 && $ocrBudgetModels[0]['timeout_ms'] >= 7999, 'reserve at least eight seconds after worst-case 0.8 second OCR cleanup'); budgetExpect(($ocrBudgetModels[0]['payload']['files'][0]['upload_file_id'] ?? '') === 'synthetic-upload-1' && str_contains($ocrBudgetModels[0]['payload']['query'], 'SYNTHETIC_ORIGINAL_QUESTION'), 'original uploaded image and original clinical task survive optional OCR timeout'); budgetExpect(($result['items'][0]['text_check_status'] ?? '') === 'unavailable', 'skipped/unfinished optional OCR remains explicit in receipt'); $cases++; } foreach ([1, 9] as $limit) { budgetReset('exhaust'); $ocrBudgetConfig['timeout'] = $limit; $result = DifyChatService::chat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', $files, ['required_files' => true]); budgetExpect(!empty($result['ok']) && $ocrBudgetReads === [] && count($ocrBudgetModels) === 1, 'insufficient optional OCR budget skips OCR while the original model can still start'); budgetExpect(($result['items'][0]['text_check_status'] ?? '') === 'unavailable', 'low-budget text-check status must not claim OCR processing'); $cases++; } $ocrBudgetConfig['timeout'] = 90; budgetReset('fixed'); $context = (object) ['ocr_remaining_seconds' => 4.0]; $options = $uploadOptions + ['ocr_context' => $context]; foreach (range(1, 3) as $batch) { $result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $options); budgetExpect(!empty($result['ok']) && count($result['files']) === 1, 'each uploaded batch remains usable with exhausted OCR allocation'); } budgetExpect(count($ocrBudgetReads) === 2 && abs($context->ocr_remaining_seconds - 1.5) < 0.00001, 'actual OCR time is deducted across batches, and insufficient work plus cleanup balance does not restart OCR'); budgetExpect(abs($ocrBudgetReads[0]['deadline'] - $ocrBudgetReads[0]['start'] - 3.2) < 0.00001 && abs($ocrBudgetReads[1]['deadline'] - $ocrBudgetReads[1]['start'] - 1.95) < 0.00001, 'each later batch receives only the shared balance after reserving cleanup'); $cases++; budgetReset('exhaust'); $context = (object) ['ocr_remaining_seconds' => 3.0]; foreach ([1, 2] as $batch) { $result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $uploadOptions + ['ocr_context' => $context]); budgetExpect(!empty($result['ok']), 'shared OCR exhaustion never removes original images'); } budgetExpect(count($ocrBudgetReads) === 1 && abs($context->ocr_remaining_seconds) < 0.00001 && abs($ocrBudgetReads[0]['deadline'] - $ocrBudgetReads[0]['start'] - 2.2) < 0.00001, 'last OCR call plus worst-case cleanup stays within the cumulative shared allocation'); $cases++; budgetReset('throw'); $context = (object) ['ocr_remaining_seconds' => 5.0]; $result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $uploadOptions + ['ocr_context' => $context]); budgetExpect(!empty($result['ok']) && $result['image_text_check']['status'] === 'unavailable' && abs($context->ocr_remaining_seconds - 3.75) < 0.00001, 'failed OCR also spends its actual elapsed time without removing the original image'); $cases++; budgetReset('fixed'); $local = $uploadOptions; $local['ocr']['timeout_seconds'] = 2.0; foreach ([1, 2] as $batch) budgetExpect(!empty(DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $local)['ok']), 'absence of a shared context uses local timeout'); budgetExpect(count($ocrBudgetReads) === 2 && abs($ocrBudgetReads[1]['deadline'] - $ocrBudgetReads[1]['start'] - 2.0) < 0.00001, 'legacy callers retain configured per-call optional timeout'); $cases++; budgetReset('exhaust'); $context = (object) ['deadline' => 1012.0, 'ocr_remaining_seconds' => 30.0]; $result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $uploadOptions + ['ocr_context' => $context]); budgetExpect(!empty($result['ok']) && 1012.0 - $ocrBudgetClock >= 8.0 - 0.00001, 'optional original absolute deadline clamps OCR even if integer upload timeout is larger'); $cases++; budgetReset('cancel'); $result = DifyChatService::streamChat('qwen', [], 'SYNTHETIC_ORIGINAL_QUESTION', 'synthetic-user', static fn (): bool => true, static fn (): bool => $GLOBALS['ocrBudgetCancelled'], $files, ['required_files' => true]); budgetExpect(empty($result['ok']) && $result['error_code'] === 'AI_ASSISTANT_CANCELLED' && $ocrBudgetModels === [], 'cancellation during optional OCR stops the whole request, never falls through to model answering'); $cases++; foreach ([0.0, 0.9, NAN] as $remaining) { budgetReset('exhaust'); $context = (object) ['ocr_remaining_seconds' => $remaining]; $result = DifyRequiredAttachmentUpload::prepare($ocrBudgetConfig['base_url'], 'SYNTHETIC_ONLY_KEY', 'synthetic-user', $files, 90, null, $uploadOptions + ['ocr_context' => $context]); budgetExpect(!empty($result['ok']) && $ocrBudgetReads === [] && $result['image_text_check']['status'] === 'unavailable', 'empty/sub-second/invalid shared OCR budget never prevents the original uploaded image'); $cases++; } budgetExpect((glob($temporary . '/*') ?: []) === [], 'authorized request-private image copies are cleaned'); echo "Dify OCR optional budget: {$cases} offline scenarios OK; eight-second model reserve, cleanup reserve, shared elapsed accounting and cancellation\n"; } finally { foreach (glob($temporary . '/*') ?: [] as $path) unlink($path); unlink($source); rmdir($public . '/uploads'); rmdir($public); rmdir($temporary); rmdir($root); } }