baseUrl() !== '' && $this->apiKey() !== ''; } /** * True while the gateway is presumed down after a recent failure — callers * on the request path should skip the network round trip entirely. */ public function circuitOpen(): bool { return Cache::has(self::CIRCUIT_KEY); } /** * @param array $context */ public function tripCircuit(array $context = []): void { $alreadyOpen = $this->circuitOpen(); $seconds = max(1, (int) config('vision.vector_gateway.circuit_breaker_seconds', 30)); Cache::put(self::CIRCUIT_KEY, true, $seconds); if ($alreadyOpen) { return; } $this->logCircuitOpened($context, $seconds); } public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array { $response = $this->postJson( $this->request(), $this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')), [ 'url' => $imageUrl, 'id' => (string) $id, 'metadata' => $metadata, ] ); if ($response->failed()) { throw new RuntimeException($this->failureMessage('Vector upsert', $response)); } $json = $response->json(); return is_array($json) ? $json : []; } /** * Store an already-computed vector directly in Qdrant. * * @param array $vector * @param array $metadata */ public function upsertByVector(array $vector, int|string $id, array $metadata = []): array { $response = $this->postJson( $this->request(), $this->url( (string) config( 'vision.vector_gateway.upsert_vector_endpoint', '/vectors/upsert/vector' ) ), [ 'vector' => array_values($vector), 'id' => (string) $id, 'metadata' => $metadata, ] ); if ($response->failed()) { throw new RuntimeException( $this->failureMessage( 'Vector upsert', $response ) ); } $json = $response->json(); return is_array($json) ? $json : []; } public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array { $response = $this->request() ->attach('file', $contents, $filename) ->post( $this->url((string) config('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file')), [ 'id' => (string) $id, 'metadata_json' => $metadata === [] ? null : json_encode($metadata, JSON_THROW_ON_ERROR), ] ); if ($response->failed()) { throw new RuntimeException($this->failureMessage('Vector upsert', $response)); } $json = $response->json(); return is_array($json) ? $json : []; } /** * @return list}> */ public function searchByUrl(string $imageUrl, int $limit = 5): array { try { $response = $this->postJson( $this->searchRequest(), $this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')), [ 'url' => $imageUrl, 'limit' => $this->clampSearchLimit($limit), ] ); } catch (Throwable $e) { throw $this->classifyThrowable('search_url', $e); } if ($response->failed()) { throw $this->classifyHttpFailure('search_url', $response); } return $this->extractMatches($response->json()); } /** * @return list}> */ public function searchByFileContents(string $contents, string $filename, int $limit = 5): array { try { $response = $this->searchRequest() ->attach('file', $contents, $filename) ->post( $this->url((string) config('vision.vector_gateway.search_file_endpoint', '/vectors/search/file')), [ 'limit' => $this->clampSearchLimit($limit), ] ); } catch (Throwable $e) { throw $this->classifyThrowable('search_file', $e); } if ($response->failed()) { throw $this->classifyHttpFailure('search_file', $response); } return $this->extractMatches($response->json()); } /** * @return list}> */ public function searchByUploadedFile(UploadedFile $file, int $limit = 5): array { $this->assertCircuitClosed(); $realPath = $file->getRealPath(); if (! is_string($realPath) || $realPath === '') { throw new RuntimeException('Uploaded file has no readable temporary path for vector search.'); } $contents = file_get_contents($realPath); if (! is_string($contents) || $contents === '') { throw new RuntimeException('Unable to read uploaded image bytes for vector search.'); } try { return $this->searchByFileContents($contents, $file->getClientOriginalName() ?: 'search-image', $limit); } catch (VectorGatewayException $e) { $this->tripIfCircuitWorthy([$e], 'uploaded_image'); throw $e; } } /** * Open the circuit only when every attempted search failure is transient/gateway-wide. * * @param list $failures * @param array{artwork_id?: int} $context */ public function tripIfCircuitWorthy(array $failures, string $source = 'unknown', array $context = []): void { if ($failures === []) { return; } foreach ($failures as $failure) { if (! $failure->circuitWorthy) { return; } } $this->tripCircuit([ 'source' => $source, 'artwork_id' => isset($context['artwork_id']) ? (int) $context['artwork_id'] : null, 'failures' => array_map(static fn (VectorGatewayException $failure): array => [ 'operation' => $failure->operation, 'http_status' => $failure->httpStatus, 'circuit_worthy' => $failure->circuitWorthy, 'exception_class' => $failure::class, ], $failures), ]); } public function assertCircuitClosed(): void { $this->guardCircuit(); } public function deleteByIds(array $ids): array { $response = $this->postJson( $this->request(), $this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')), [ 'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)), ] ); if ($response->failed()) { throw new RuntimeException($this->failureMessage('Vector delete', $response)); } $json = $response->json(); return is_array($json) ? $json : []; } /** * Search using an artwork vector that is already stored in Qdrant. * * Returns null when the source point does not exist so the caller may fall * back to the existing image/CLIP search path. * * @return list}>|null */ public function searchByPointId(int $artworkId, int $limit = 5): ?array { try { $response = $this->postJson( $this->searchRequest(), $this->url( (string) config( 'vision.vector_gateway.search_point_endpoint', '/vectors/search/point' ) ), [ 'id' => $artworkId, 'limit' => $this->clampSearchLimit($limit), ] ); } catch (Throwable $e) { throw $this->classifyThrowable('search_point', $e); } // Missing indexed vector is expected and is not a gateway failure. if ($response->status() === 404) { return null; } if ($response->failed()) { throw $this->classifyHttpFailure('search_point', $response); } return $this->extractMatches($response->json()); } /** * Used by upsert/delete — only ever called from queued/console indexing * jobs, so a more generous budget is fine. */ private function request(): PendingRequest { if (! $this->isConfigured()) { throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.'); } return Http::acceptJson() ->withHeaders([ 'X-API-Key' => $this->apiKey(), ]) ->connectTimeout(max(1, (int) config('vision.vector_gateway.connect_timeout_seconds', 5))) ->timeout(max(1, (int) config('vision.vector_gateway.timeout_seconds', 20))) ->retry( max(0, (int) config('vision.vector_gateway.retries', 1)), max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)), throw: false, ); } /** * Used by search — runs synchronously inside web requests, so it gets a * tight timeout budget and no retries to avoid pinning FPM workers. */ private function searchRequest(): PendingRequest { if (! $this->isConfigured()) { throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.'); } return Http::acceptJson() ->withHeaders([ 'X-API-Key' => $this->apiKey(), ]) ->connectTimeout(max(1, (int) config('vision.vector_gateway.search_connect_timeout_seconds', 2))) ->timeout(max(1, (int) config('vision.vector_gateway.search_timeout_seconds', 6))) ->retry( max(0, (int) config('vision.vector_gateway.search_retries', 0)), max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)), throw: false, ); } /** * @param array $context */ private function logCircuitOpened(array $context, int $ttlSeconds): void { $payload = [ 'source' => (string) ($context['source'] ?? 'unknown'), 'failures' => is_array($context['failures'] ?? null) ? $context['failures'] : [], 'circuit_ttl_seconds' => $ttlSeconds, 'php_sapi' => PHP_SAPI, 'running_in_console' => app()->runningInConsole(), ]; if (isset($context['artwork_id']) && is_int($context['artwork_id']) && $context['artwork_id'] > 0) { $payload['artwork_id'] = $context['artwork_id']; } Log::warning('Vector gateway circuit opened', $payload); } private function clampSearchLimit(int $limit): int { return max(1, min(self::MAX_SEARCH_LIMIT, $limit)); } private function guardCircuit(): void { if ($this->circuitOpen()) { throw new VectorGatewayException( 'Vector gateway temporarily unavailable.', 'circuit', null, true, ); } } private function classifyHttpFailure(string $operation, Response $response): VectorGatewayException { $status = $response->status(); return new VectorGatewayException( 'Vector gateway '.$operation.' failed with HTTP '.$status.'.', $operation, $status, $this->isCircuitWorthyStatus($status), ); } private function classifyThrowable(string $operation, Throwable $e): VectorGatewayException { if ($e instanceof VectorGatewayException) { return $e; } if ($e instanceof RequestException && $e->response instanceof Response) { return $this->classifyHttpFailure($operation, $e->response); } $circuitWorthy = $e instanceof ConnectionException || $this->looksLikeTimeout($e); return new VectorGatewayException( 'Vector gateway '.$operation.' failed.', $operation, null, $circuitWorthy, $e, ); } private function isCircuitWorthyStatus(int $status): bool { return $status === 408 || $status === 429 || $status >= 500; } private function looksLikeTimeout(Throwable $e): bool { $message = strtolower($e->getMessage()); return str_contains($message, 'timed out') || str_contains($message, 'timeout') || str_contains($message, 'curl error 28') || str_contains($message, 'connection refused') || str_contains($message, 'could not resolve'); } /** * @param array $payload */ private function postJson(PendingRequest $request, string $url, array $payload): Response { $response = $request->post($url, $payload); if (! $response instanceof Response) { throw new RuntimeException('Vector gateway request did not return an HTTP response.'); } return $response; } private function baseUrl(): string { return rtrim((string) config('vision.vector_gateway.base_url', ''), '/'); } private function apiKey(): string { return trim((string) config('vision.vector_gateway.api_key', '')); } private function url(string $path): string { return $this->baseUrl() . '/' . ltrim($path, '/'); } private function failureMessage(string $operation, Response $response): string { $body = trim($response->body()); if ($body === '') { return $operation . ' failed with HTTP ' . $response->status() . '.'; } return $operation . ' failed with HTTP ' . $response->status() . ': ' . $body; } /** * @param mixed $json * @return list}> */ private function extractMatches(mixed $json): array { $candidates = []; if (is_array($json)) { $candidates = $this->extractCandidateRows($json); } $results = []; foreach ($candidates as $candidate) { if (! is_array($candidate)) { continue; } $id = $candidate['id'] ?? $candidate['point_id'] ?? $candidate['payload']['id'] ?? $candidate['metadata']['id'] ?? null; if (! is_int($id) && ! is_string($id)) { continue; } $score = $candidate['score'] ?? $candidate['similarity'] ?? $candidate['distance'] ?? 0.0; $metadata = $candidate['metadata'] ?? $candidate['payload'] ?? []; if (! is_array($metadata)) { $metadata = []; } $results[] = [ 'id' => $id, 'score' => (float) $score, 'metadata' => $metadata, ]; } return $results; } /** * @param array $json * @return array */ private function extractCandidateRows(array $json): array { $keys = ['results', 'matches', 'points', 'data']; foreach ($keys as $key) { if (! isset($json[$key]) || ! is_array($json[$key])) { continue; } $value = $json[$key]; if (array_is_list($value)) { return $value; } foreach (['results', 'matches', 'points', 'items'] as $nestedKey) { if (isset($value[$nestedKey]) && is_array($value[$nestedKey]) && array_is_list($value[$nestedKey])) { return $value[$nestedKey]; } } } return array_is_list($json) ? $json : []; } }