From 4c1fa56d1728ea410788d8950364b9aaae55d94b Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 24 Apr 2026 05:21:15 +0100 Subject: [PATCH] fix(brain): wire Qdrant api-key header from BRAIN_QDRANT_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BrainService::http() was building a PendingRequest with no auth header, so when Qdrant has auth enabled (the production lthn.sh deploy does) every upsert/lookup returned 401. The circuit breaker logged the 401 via Cache::store('file'), which was the red-herring cache-write error chased in the first #97 iteration. Changes: - BrainService loads + trims a Qdrant api key from config('brain.qdrant.api_key') in the constructor. - New qdrantHttp() helper returns a PendingRequest with the api-key header when the key is non-empty, or the plain client otherwise. Ollama + Elasticsearch call sites still use http() (separate auth shapes). - php/config.php adds a brain.qdrant.api_key entry reading env('BRAIN_QDRANT_API_KEY'). - Good/Bad/Ugly Pest tests cover: configured key → header sent, unset → header absent, empty-string → header absent. Closes tasks.lthn.sh/view.php?id=97 Co-authored-by: Codex Co-Authored-By: Virgil --- php/Services/BrainService.php | 38 ++++++++++++--- php/config.php | 3 ++ php/tests/Unit/BrainServiceTest.php | 74 +++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 php/tests/Unit/BrainServiceTest.php diff --git a/php/Services/BrainService.php b/php/Services/BrainService.php index 2f643ce..bef91c6 100644 --- a/php/Services/BrainService.php +++ b/php/Services/BrainService.php @@ -20,6 +20,8 @@ class BrainService private const ELASTIC_INDEX = 'brain_memories'; + private string $qdrantApiKey; + public function __construct( private string $ollamaUrl = 'http://localhost:11434', private string $qdrantUrl = 'http://localhost:6334', @@ -27,7 +29,15 @@ class BrainService private string $embeddingModel = self::DEFAULT_MODEL, private bool $verifySsl = true, private string $elasticsearchUrl = 'http://127.0.0.1:9200', - ) {} + ?string $qdrantApiKey = null, + ) { + if ($qdrantApiKey === null && function_exists('config')) { + $configuredQdrantApiKey = config('mcp.brain.qdrant.api_key', config('brain.qdrant.api_key', '')); + $qdrantApiKey = is_string($configuredQdrantApiKey) ? $configuredQdrantApiKey : ''; + } + + $this->qdrantApiKey = trim((string) $qdrantApiKey); + } /** * Create an HTTP client with common settings. @@ -39,6 +49,22 @@ class BrainService : Http::withoutVerifying()->timeout($timeout); } + /** + * Create an HTTP client for Qdrant requests. + */ + private function qdrantHttp(int $timeout = 10): PendingRequest + { + $request = $this->http($timeout); + + if ($this->qdrantApiKey === '') { + return $request; + } + + return $request->withHeaders([ + 'api-key' => $this->qdrantApiKey, + ]); + } + /** * Generate an embedding vector for the given text. * @@ -114,7 +140,7 @@ class BrainService $filter['workspace_id'] = $workspaceId; $qdrantFilter = $this->buildQdrantFilter($filter); - $response = $this->http(10) + $response = $this->qdrantHttp(10) ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [ 'vector' => $vector, 'filter' => $qdrantFilter, @@ -200,11 +226,11 @@ class BrainService */ public function ensureCollection(): void { - $response = $this->http(5) + $response = $this->qdrantHttp(5) ->get("{$this->qdrantUrl}/collections/{$this->collection}"); if ($response->status() === 404) { - $createResponse = $this->http(10) + $createResponse = $this->qdrantHttp(10) ->put("{$this->qdrantUrl}/collections/{$this->collection}", [ 'vectors' => [ 'size' => self::VECTOR_DIMENSION, @@ -552,7 +578,7 @@ class BrainService */ public function qdrantUpsert(array $points): void { - $response = $this->http(10) + $response = $this->qdrantHttp(10) ->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [ 'points' => $points, ]); @@ -572,7 +598,7 @@ class BrainService */ public function qdrantDelete(array $ids): void { - $response = $this->http(10) + $response = $this->qdrantHttp(10) ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [ 'points' => $ids, ]); diff --git a/php/config.php b/php/config.php index 1599522..a309a88 100644 --- a/php/config.php +++ b/php/config.php @@ -81,6 +81,9 @@ return [ 'brain' => [ 'ollama_url' => env('BRAIN_OLLAMA_URL', 'https://ollama.lthn.sh'), 'qdrant_url' => env('BRAIN_QDRANT_URL', 'https://qdrant.lthn.sh'), + 'qdrant' => [ + 'api_key' => env('BRAIN_QDRANT_API_KEY', ''), + ], 'collection' => env('BRAIN_COLLECTION', 'openbrain'), 'embedding_model' => env('BRAIN_EMBEDDING_MODEL', 'embeddinggemma'), diff --git a/php/tests/Unit/BrainServiceTest.php b/php/tests/Unit/BrainServiceTest.php new file mode 100644 index 0000000..e27a558 --- /dev/null +++ b/php/tests/Unit/BrainServiceTest.php @@ -0,0 +1,74 @@ + Http::response(['status' => 'ok']), + ]); + + qdrantHeaderBrainService()->qdrantUpsert([ + ['id' => 'memory-1', 'vector' => [0.1, 0.2], 'payload' => ['type' => 'note']], + ]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points' + && $request->method() === 'PUT' + && $request->hasHeader('api-key', 'qdrant-secret')); +}); + +test('BrainService_qdrantUpsert_Bad_omits_api_key_header_when_unset', function (): void { + Config::set('mcp.brain.qdrant.api_key', null); + + Http::fake([ + 'https://qdrant.test/collections/openbrain/points' => Http::response(['status' => 'ok']), + ]); + + qdrantHeaderBrainService()->qdrantUpsert([ + ['id' => 'memory-2', 'vector' => [0.3, 0.4], 'payload' => ['type' => 'note']], + ]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points' + && $request->method() === 'PUT' + && ! $request->hasHeader('api-key')); +}); + +test('BrainService_qdrantUpsert_Ugly_treats_empty_api_key_as_unset', function (): void { + Config::set('mcp.brain.qdrant.api_key', ''); + + Http::fake([ + 'https://qdrant.test/collections/openbrain/points' => Http::response(['status' => 'ok']), + ]); + + qdrantHeaderBrainService()->qdrantUpsert([ + ['id' => 'memory-3', 'vector' => [0.5, 0.6], 'payload' => ['type' => 'note']], + ]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points' + && $request->method() === 'PUT' + && ! $request->hasHeader('api-key')); +});