From 7604609efa68c0b9aaa9191b5f227db3585df4b9 Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 6 Aug 2025 19:00:17 +0800 Subject: [PATCH 01/10] Add method to update workspace settings --- src/Client.php | 4 ++ src/Endpoints/ChatWorkspaces.php | 31 +++++++++++++ .../Delegates/HandlesChatWorkspaces.php | 12 +++++ tests/Endpoints/ChatTest.php | 46 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 src/Endpoints/ChatWorkspaces.php create mode 100644 src/Endpoints/Delegates/HandlesChatWorkspaces.php create mode 100644 tests/Endpoints/ChatTest.php diff --git a/src/Client.php b/src/Client.php index dffa598a..9baf3f3c 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,7 +5,9 @@ namespace Meilisearch; use Meilisearch\Endpoints\Batches; +use Meilisearch\Endpoints\ChatWorkspaces; use Meilisearch\Endpoints\Delegates\HandlesBatches; +use Meilisearch\Endpoints\Delegates\HandlesChatWorkspaces; use Meilisearch\Endpoints\Delegates\HandlesDumps; use Meilisearch\Endpoints\Delegates\HandlesIndex; use Meilisearch\Endpoints\Delegates\HandlesKeys; @@ -31,6 +33,7 @@ class Client { + use HandlesChatWorkspaces; use HandlesDumps; use HandlesIndex; use HandlesTasks; @@ -53,6 +56,7 @@ public function __construct( ?StreamFactoryInterface $streamFactory = null ) { $this->http = new MeilisearchClientAdapter($url, $apiKey, $httpClient, $requestFactory, $clientAgents, $streamFactory); + $this->chats = new ChatWorkspaces($this->http); $this->index = new Indexes($this->http); $this->health = new Health($this->http); $this->version = new Version($this->http); diff --git a/src/Endpoints/ChatWorkspaces.php b/src/Endpoints/ChatWorkspaces.php new file mode 100644 index 00000000..f2a8c3df --- /dev/null +++ b/src/Endpoints/ChatWorkspaces.php @@ -0,0 +1,31 @@ +workspaceName = $workspaceName; + parent::__construct($http); + } + + public function workspace(string $workspaceName): self + { + return new self($this->http, $workspaceName); + } + + public function updateSettings(array $settings): array + { + return $this->http->patch(self::PATH.'/'.$this->workspaceName.'/settings', $settings); + } +} diff --git a/src/Endpoints/Delegates/HandlesChatWorkspaces.php b/src/Endpoints/Delegates/HandlesChatWorkspaces.php new file mode 100644 index 00000000..8504bea5 --- /dev/null +++ b/src/Endpoints/Delegates/HandlesChatWorkspaces.php @@ -0,0 +1,12 @@ + "openAi", + "orgId" => "some-org-id", + "projectId" => "some-project-id", + "apiVersion" => "some-api-version", + "deploymentId" => "some-deployment-id", + "baseUrl" => "https://baseurl.com", + "apiKey" => "sk-abc...", + "prompts" => [ + "system" => "You are a helpful assistant that answers questions based on the provided context.", + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + $http = new Client($this->host, getenv('MEILISEARCH_API_KEY')); + $http->patch('/experimental-features', ['chatCompletions' => true]); + } + + public function testUpdateWorkspacesSettings(): void + { + $response = $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); + self::assertSame($this->workspaceSettings['source'], $response['source']); + self::assertSame($this->workspaceSettings['orgId'], $response['orgId']); + self::assertSame($this->workspaceSettings['projectId'], $response['projectId']); + self::assertSame($this->workspaceSettings['apiVersion'], $response['apiVersion']); + self::assertSame($this->workspaceSettings['deploymentId'], $response['deploymentId']); + self::assertSame($this->workspaceSettings['baseUrl'], $response['baseUrl']); + self::assertSame($this->workspaceSettings['prompts']['system'], $response['prompts']['system']); + // Meilisearch will mask the API key in the response + self::assertSame('XXX...', $response['apiKey']); + } +} From 4897fbfc09928fd96db2a6a8b9c7638b53dce528 Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 13 Aug 2025 17:13:17 +0800 Subject: [PATCH 02/10] add CRUD methods --- src/Endpoints/ChatWorkspaces.php | 15 +++++++++++ tests/Endpoints/ChatTest.php | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/Endpoints/ChatWorkspaces.php b/src/Endpoints/ChatWorkspaces.php index f2a8c3df..ad50e56b 100644 --- a/src/Endpoints/ChatWorkspaces.php +++ b/src/Endpoints/ChatWorkspaces.php @@ -19,13 +19,28 @@ public function __construct(Http $http, ?string $workspaceName = null) parent::__construct($http); } + public function listWorkspaces(): array + { + return $this->http->get(self::PATH); + } + public function workspace(string $workspaceName): self { return new self($this->http, $workspaceName); } + public function getSettings(): array + { + return $this->http->get(self::PATH.'/'.$this->workspaceName.'/settings'); + } + public function updateSettings(array $settings): array { return $this->http->patch(self::PATH.'/'.$this->workspaceName.'/settings', $settings); } + + public function resetSettings(): array + { + return $this->http->delete(self::PATH.'/'.$this->workspaceName.'/settings'); + } } diff --git a/tests/Endpoints/ChatTest.php b/tests/Endpoints/ChatTest.php index 135da303..589f1471 100644 --- a/tests/Endpoints/ChatTest.php +++ b/tests/Endpoints/ChatTest.php @@ -43,4 +43,48 @@ public function testUpdateWorkspacesSettings(): void // Meilisearch will mask the API key in the response self::assertSame('XXX...', $response['apiKey']); } + + public function testGetWorkspaceSettings(): void + { + $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); + + $response = $this->client->chats->workspace('myWorkspace')->getSettings(); + self::assertSame($this->workspaceSettings['source'], $response['source']); + self::assertSame($this->workspaceSettings['orgId'], $response['orgId']); + self::assertSame($this->workspaceSettings['projectId'], $response['projectId']); + self::assertSame($this->workspaceSettings['apiVersion'], $response['apiVersion']); + self::assertSame($this->workspaceSettings['deploymentId'], $response['deploymentId']); + self::assertSame($this->workspaceSettings['baseUrl'], $response['baseUrl']); + self::assertSame($this->workspaceSettings['prompts']['system'], $response['prompts']['system']); + // Meilisearch will mask the API key in the response + self::assertSame('XXX...', $response['apiKey']); + } + + public function testListWorkspaces(): void + { + $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); + $response = $this->client->chats->listWorkspaces(); + self::assertSame([ + ['uid' => 'myWorkspace'], + ], $response['results']); + } + + public function testDeleteWorkspaceSettings(): void + { + $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); + $this->client->chats->workspace('myWorkspace')->resetSettings(); + $settingsResponse = $this->client->chats->workspace('myWorkspace')->getSettings(); + self::assertSame('openAi', $settingsResponse['source']); + self::assertNull($settingsResponse['orgId']); + self::assertNull($settingsResponse['projectId']); + self::assertNull($settingsResponse['apiVersion']); + self::assertNull($settingsResponse['deploymentId']); + self::assertNull($settingsResponse['baseUrl']); + self::assertNull($settingsResponse['apiKey']); + + $listResponse = $this->client->chats->listWorkspaces(); + self::assertSame([ + ['uid' => 'myWorkspace'], + ], $listResponse['results']); + } } From 51f440016aef8e8773075a2694519e4ab48d0e72 Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 13 Aug 2025 17:33:55 +0800 Subject: [PATCH 03/10] Refactor --- src/Contracts/ChatWorkspaceSettings.php | 88 +++++++++++++++++++ src/Contracts/ChatWorkspacesResults.php | 59 +++++++++++++ src/Endpoints/ChatWorkspaces.php | 26 ++---- .../HandlesChatWorkspaceSettings.php | 68 ++++++++++++++ .../Delegates/HandlesChatWorkspaces.php | 17 ++++ tests/Endpoints/ChatTest.php | 50 +++++------ 6 files changed, 266 insertions(+), 42 deletions(-) create mode 100644 src/Contracts/ChatWorkspaceSettings.php create mode 100644 src/Contracts/ChatWorkspacesResults.php create mode 100644 src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php diff --git a/src/Contracts/ChatWorkspaceSettings.php b/src/Contracts/ChatWorkspaceSettings.php new file mode 100644 index 00000000..5aaf4953 --- /dev/null +++ b/src/Contracts/ChatWorkspaceSettings.php @@ -0,0 +1,88 @@ +source = $params['source'] ?? null; + $this->orgId = $params['orgId'] ?? null; + $this->projectId = $params['projectId'] ?? null; + $this->apiVersion = $params['apiVersion'] ?? null; + $this->deploymentId = $params['deploymentId'] ?? null; + $this->baseUrl = $params['baseUrl'] ?? null; + $this->apiKey = $params['apiKey'] ?? null; + $this->prompts = $params['prompts'] ?? []; + } + + public function getSource(): ?string + { + return $this->source; + } + + public function getOrgId(): ?string + { + return $this->orgId; + } + + public function getProjectId(): ?string + { + return $this->projectId; + } + + public function getApiVersion(): ?string + { + return $this->apiVersion; + } + + public function getDeploymentId(): ?string + { + return $this->deploymentId; + } + + public function getBaseUrl(): ?string + { + return $this->baseUrl; + } + + public function getApiKey(): ?string + { + return $this->apiKey; + } + + /** + * @return array{system?: string, searchDescription?: string, searchQParam?: string, searchIndexUidParam?: string} + */ + public function getPrompts(): array + { + return $this->prompts; + } + + public function toArray(): array + { + return [ + 'source' => $this->source, + 'orgId' => $this->orgId, + 'projectId' => $this->projectId, + 'apiVersion' => $this->apiVersion, + 'deploymentId' => $this->deploymentId, + 'baseUrl' => $this->baseUrl, + 'apiKey' => $this->apiKey, + 'prompts' => $this->prompts, + ]; + } +} diff --git a/src/Contracts/ChatWorkspacesResults.php b/src/Contracts/ChatWorkspacesResults.php new file mode 100644 index 00000000..cfcb6d2d --- /dev/null +++ b/src/Contracts/ChatWorkspacesResults.php @@ -0,0 +1,59 @@ +offset = $params['offset']; + $this->limit = $params['limit']; + $this->total = $params['total'] ?? 0; + } + + /** + * @return array + */ + public function getResults(): array + { + return $this->data; + } + + public function getOffset(): int + { + return $this->offset; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getTotal(): int + { + return $this->total; + } + + public function toArray(): array + { + return [ + 'results' => $this->data, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'total' => $this->total, + ]; + } + + public function count(): int + { + return \count($this->data); + } +} diff --git a/src/Endpoints/ChatWorkspaces.php b/src/Endpoints/ChatWorkspaces.php index ad50e56b..3847fdec 100644 --- a/src/Endpoints/ChatWorkspaces.php +++ b/src/Endpoints/ChatWorkspaces.php @@ -4,11 +4,16 @@ namespace Meilisearch\Endpoints; +use Meilisearch\Contracts\ChatWorkspacesResults; +use Meilisearch\Contracts\ChatWorkspaceSettings; use Meilisearch\Contracts\Endpoint; use Meilisearch\Contracts\Http; +use Meilisearch\Endpoints\Delegates\HandlesChatWorkspaceSettings; class ChatWorkspaces extends Endpoint { + use HandlesChatWorkspaceSettings; + protected const PATH = '/chats'; private ?string $workspaceName; @@ -19,28 +24,15 @@ public function __construct(Http $http, ?string $workspaceName = null) parent::__construct($http); } - public function listWorkspaces(): array + public function listWorkspaces(): ChatWorkspacesResults { - return $this->http->get(self::PATH); + $response = $this->http->get(self::PATH); + + return new ChatWorkspacesResults($response); } public function workspace(string $workspaceName): self { return new self($this->http, $workspaceName); } - - public function getSettings(): array - { - return $this->http->get(self::PATH.'/'.$this->workspaceName.'/settings'); - } - - public function updateSettings(array $settings): array - { - return $this->http->patch(self::PATH.'/'.$this->workspaceName.'/settings', $settings); - } - - public function resetSettings(): array - { - return $this->http->delete(self::PATH.'/'.$this->workspaceName.'/settings'); - } } diff --git a/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php new file mode 100644 index 00000000..ad934c8f --- /dev/null +++ b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php @@ -0,0 +1,68 @@ +workspaceName) { + throw new \InvalidArgumentException('Workspace name is required to get settings'); + } + + $response = $this->http->get('/chats/'.$this->workspaceName.'/settings'); + + return new ChatWorkspaceSettings($response); + } + + /** + * Update the settings for this chat workspace. + * + * @param array{ + * source?: 'openAi'|'azureOpenAi'|'mistral'|'gemini'|'vLlm', + * orgId?: string, + * projectId?: string, + * apiVersion?: string, + * deploymentId?: string, + * baseUrl?: string, + * apiKey?: string, + * prompts?: array{ + * system?: string, + * searchDescription?: string, + * searchQParam?: string, + * searchIndexUidParam?: string + * } + * } $settings + */ + public function updateSettings(array $settings): ChatWorkspaceSettings + { + if (!$this->workspaceName) { + throw new \InvalidArgumentException('Workspace name is required to update settings'); + } + + $response = $this->http->patch('/chats/'.$this->workspaceName.'/settings', $settings); + + return new ChatWorkspaceSettings($response); + } + + /** + * Reset the settings for this chat workspace to default values. + */ + public function resetSettings(): ChatWorkspaceSettings + { + if (!$this->workspaceName) { + throw new \InvalidArgumentException('Workspace name is required to reset settings'); + } + + $response = $this->http->delete('/chats/'.$this->workspaceName.'/settings'); + + return new ChatWorkspaceSettings($response); + } +} diff --git a/src/Endpoints/Delegates/HandlesChatWorkspaces.php b/src/Endpoints/Delegates/HandlesChatWorkspaces.php index 8504bea5..343a6a51 100644 --- a/src/Endpoints/Delegates/HandlesChatWorkspaces.php +++ b/src/Endpoints/Delegates/HandlesChatWorkspaces.php @@ -4,9 +4,26 @@ namespace Meilisearch\Endpoints\Delegates; +use Meilisearch\Contracts\ChatWorkspacesResults; use Meilisearch\Endpoints\ChatWorkspaces; trait HandlesChatWorkspaces { public ChatWorkspaces $chats; + + /** + * List all chat workspaces. + */ + public function getChatWorkspaces(): ChatWorkspacesResults + { + return $this->chats->listWorkspaces(); + } + + /** + * Get a specific chat workspace instance. + */ + public function chatWorkspace(string $workspaceName): ChatWorkspaces + { + return $this->chats->workspace($workspaceName); + } } diff --git a/tests/Endpoints/ChatTest.php b/tests/Endpoints/ChatTest.php index 589f1471..d5e9be37 100644 --- a/tests/Endpoints/ChatTest.php +++ b/tests/Endpoints/ChatTest.php @@ -33,15 +33,15 @@ protected function setUp(): void public function testUpdateWorkspacesSettings(): void { $response = $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); - self::assertSame($this->workspaceSettings['source'], $response['source']); - self::assertSame($this->workspaceSettings['orgId'], $response['orgId']); - self::assertSame($this->workspaceSettings['projectId'], $response['projectId']); - self::assertSame($this->workspaceSettings['apiVersion'], $response['apiVersion']); - self::assertSame($this->workspaceSettings['deploymentId'], $response['deploymentId']); - self::assertSame($this->workspaceSettings['baseUrl'], $response['baseUrl']); - self::assertSame($this->workspaceSettings['prompts']['system'], $response['prompts']['system']); + self::assertSame($this->workspaceSettings['source'], $response->getSource()); + self::assertSame($this->workspaceSettings['orgId'], $response->getOrgId()); + self::assertSame($this->workspaceSettings['projectId'], $response->getProjectId()); + self::assertSame($this->workspaceSettings['apiVersion'], $response->getApiVersion()); + self::assertSame($this->workspaceSettings['deploymentId'], $response->getDeploymentId()); + self::assertSame($this->workspaceSettings['baseUrl'], $response->getBaseUrl()); + self::assertSame($this->workspaceSettings['prompts']['system'], $response->getPrompts()['system']); // Meilisearch will mask the API key in the response - self::assertSame('XXX...', $response['apiKey']); + self::assertSame('XXX...', $response->getApiKey()); } public function testGetWorkspaceSettings(): void @@ -49,15 +49,15 @@ public function testGetWorkspaceSettings(): void $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); $response = $this->client->chats->workspace('myWorkspace')->getSettings(); - self::assertSame($this->workspaceSettings['source'], $response['source']); - self::assertSame($this->workspaceSettings['orgId'], $response['orgId']); - self::assertSame($this->workspaceSettings['projectId'], $response['projectId']); - self::assertSame($this->workspaceSettings['apiVersion'], $response['apiVersion']); - self::assertSame($this->workspaceSettings['deploymentId'], $response['deploymentId']); - self::assertSame($this->workspaceSettings['baseUrl'], $response['baseUrl']); - self::assertSame($this->workspaceSettings['prompts']['system'], $response['prompts']['system']); + self::assertSame($this->workspaceSettings['source'], $response->getSource()); + self::assertSame($this->workspaceSettings['orgId'], $response->getOrgId()); + self::assertSame($this->workspaceSettings['projectId'], $response->getProjectId()); + self::assertSame($this->workspaceSettings['apiVersion'], $response->getApiVersion()); + self::assertSame($this->workspaceSettings['deploymentId'], $response->getDeploymentId()); + self::assertSame($this->workspaceSettings['baseUrl'], $response->getBaseUrl()); + self::assertSame($this->workspaceSettings['prompts']['system'], $response->getPrompts()['system']); // Meilisearch will mask the API key in the response - self::assertSame('XXX...', $response['apiKey']); + self::assertSame('XXX...', $response->getApiKey()); } public function testListWorkspaces(): void @@ -66,7 +66,7 @@ public function testListWorkspaces(): void $response = $this->client->chats->listWorkspaces(); self::assertSame([ ['uid' => 'myWorkspace'], - ], $response['results']); + ], $response->getResults()); } public function testDeleteWorkspaceSettings(): void @@ -74,17 +74,17 @@ public function testDeleteWorkspaceSettings(): void $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); $this->client->chats->workspace('myWorkspace')->resetSettings(); $settingsResponse = $this->client->chats->workspace('myWorkspace')->getSettings(); - self::assertSame('openAi', $settingsResponse['source']); - self::assertNull($settingsResponse['orgId']); - self::assertNull($settingsResponse['projectId']); - self::assertNull($settingsResponse['apiVersion']); - self::assertNull($settingsResponse['deploymentId']); - self::assertNull($settingsResponse['baseUrl']); - self::assertNull($settingsResponse['apiKey']); + self::assertSame('openAi', $settingsResponse->getSource()); + self::assertNull($settingsResponse->getOrgId()); + self::assertNull($settingsResponse->getProjectId()); + self::assertNull($settingsResponse->getApiVersion()); + self::assertNull($settingsResponse->getDeploymentId()); + self::assertNull($settingsResponse->getBaseUrl()); + self::assertNull($settingsResponse->getApiKey()); $listResponse = $this->client->chats->listWorkspaces(); self::assertSame([ ['uid' => 'myWorkspace'], - ], $listResponse['results']); + ], $listResponse->getResults()); } } From cd2b8dfbded6ba8cb39f6e12390779c8d7dc472d Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 13 Aug 2025 18:01:17 +0800 Subject: [PATCH 04/10] Add chat completion streaming --- src/Contracts/Http.php | 6 ++ .../HandlesChatWorkspaceSettings.php | 26 ++++++++- src/Http/Client.php | 55 +++++++++++++++++++ .../{ChatTest.php => ChatWorkspaceTest.php} | 45 ++++++++++++++- 4 files changed, 128 insertions(+), 4 deletions(-) rename tests/Endpoints/{ChatTest.php => ChatWorkspaceTest.php} (73%) diff --git a/src/Contracts/Http.php b/src/Contracts/Http.php index d20d0e64..cd9d1b7b 100644 --- a/src/Contracts/Http.php +++ b/src/Contracts/Http.php @@ -46,4 +46,10 @@ public function patch(string $path, $body = null, array $query = []); * @throws JsonDecodingException */ public function delete(string $path, array $query = []); + + /** + * @throws ApiException + * @throws JsonEncodingException + */ + public function postStream(string $path, $body = null, array $query = []): \Psr\Http\Message\StreamInterface; } diff --git a/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php index ad934c8f..80b6f05d 100644 --- a/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php +++ b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php @@ -13,7 +13,7 @@ trait HandlesChatWorkspaceSettings */ public function getSettings(): ChatWorkspaceSettings { - if (!$this->workspaceName) { + if (null === $this->workspaceName) { throw new \InvalidArgumentException('Workspace name is required to get settings'); } @@ -43,7 +43,7 @@ public function getSettings(): ChatWorkspaceSettings */ public function updateSettings(array $settings): ChatWorkspaceSettings { - if (!$this->workspaceName) { + if (null === $this->workspaceName) { throw new \InvalidArgumentException('Workspace name is required to update settings'); } @@ -57,7 +57,7 @@ public function updateSettings(array $settings): ChatWorkspaceSettings */ public function resetSettings(): ChatWorkspaceSettings { - if (!$this->workspaceName) { + if (null === $this->workspaceName) { throw new \InvalidArgumentException('Workspace name is required to reset settings'); } @@ -65,4 +65,24 @@ public function resetSettings(): ChatWorkspaceSettings return new ChatWorkspaceSettings($response); } + + /** + * Create a streaming chat completion. + * + * @param array{ + * model: string, + * messages: array, + * stream: bool, + * temperature?: float, + * max_tokens?: int + * } $options + */ + public function streamCompletion(array $options): \Psr\Http\Message\StreamInterface + { + if (null === $this->workspaceName) { + throw new \InvalidArgumentException('Workspace name is required for chat completion'); + } + + return $this->http->postStream('/chats/'.$this->workspaceName.'/chat/completions', $options); + } } diff --git a/src/Http/Client.php b/src/Http/Client.php index 9eef6472..87a0019d 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -139,6 +139,22 @@ public function delete(string $path, array $query = []) return $this->execute($request); } + /** + * @throws ApiException + * @throws ClientExceptionInterface + * @throws CommunicationException + * @throws JsonEncodingException + */ + public function postStream(string $path, $body = null, array $query = []): \Psr\Http\Message\StreamInterface + { + $request = $this->requestFactory->createRequest( + 'POST', + $this->baseUrl.$path.$this->buildQueryString($query) + )->withBody($this->streamFactory->createStream($this->json->serialize($body))); + + return $this->executeStream($request, ['Content-type' => 'application/json']); + } + /** * @param array $headers * @@ -159,6 +175,45 @@ private function execute(RequestInterface $request, array $headers = []) } } + /** + * @param array $headers + * + * @throws ApiException + * @throws ClientExceptionInterface + * @throws CommunicationException + */ + private function executeStream(RequestInterface $request, array $headers = []): \Psr\Http\Message\StreamInterface + { + foreach (array_merge($this->headers, $headers) as $header => $value) { + $request = $request->withAddedHeader($header, $value); + } + + try { + $response = $this->http->sendRequest($request); + + if ($response->getStatusCode() >= 300) { + $bodyContent = (string) $response->getBody(); + + // Try to parse as JSON for structured errors, fall back to raw content + if ($this->isJSONResponse($response->getHeader('content-type'))) { + try { + $body = $this->json->unserialize($bodyContent) ?? $response->getReasonPhrase(); + } catch (\JsonException $e) { + $body = $bodyContent ?: $response->getReasonPhrase(); + } + } else { + $body = $bodyContent ?: $response->getReasonPhrase(); + } + + throw new ApiException($response, $body); + } + + return $response->getBody(); + } catch (NetworkExceptionInterface $e) { + throw new CommunicationException($e->getMessage(), $e->getCode(), $e); + } + } + private function buildQueryString(array $queryParams = []): string { return \count($queryParams) > 0 ? '?'.http_build_query($queryParams) : ''; diff --git a/tests/Endpoints/ChatTest.php b/tests/Endpoints/ChatWorkspaceTest.php similarity index 73% rename from tests/Endpoints/ChatTest.php rename to tests/Endpoints/ChatWorkspaceTest.php index d5e9be37..66b1b9ea 100644 --- a/tests/Endpoints/ChatTest.php +++ b/tests/Endpoints/ChatWorkspaceTest.php @@ -4,10 +4,11 @@ namespace Tests\Endpoints; +use Meilisearch\Client as MeilisearchClient; use Meilisearch\Http\Client; use Tests\TestCase; -final class ChatTest extends TestCase +final class ChatWorkspaceTest extends TestCase { private array $workspaceSettings = [ "source" => "openAi", @@ -87,4 +88,46 @@ public function testDeleteWorkspaceSettings(): void ['uid' => 'myWorkspace'], ], $listResponse->getResults()); } + + public function testCompletionStreaming(): void + { + $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); + + $stream = $this->client->chats->workspace('myWorkspace')->streamCompletion([ + 'model' => 'gpt-4o-mini', + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Hello, how are you?', + ], + ], + 'stream' => true, + ]); + + $receivedData = ''; + $chunkCount = 0; + $maxChunks = 1000; // Safety limit + + try { + while (!$stream->eof() && $chunkCount < $maxChunks) { + $chunk = $stream->read(8192); + if ('' === $chunk) { + // Small backoff to avoid tight loop on empty reads + usleep(10_000); + continue; + } + $receivedData .= $chunk; + $chunkCount++; + } + + if ($chunkCount >= $maxChunks) { + self::fail('Test exceeded maximum chunk limit of '.$maxChunks); + } + + self::assertGreaterThan(0, strlen($receivedData)); + } finally { + // Ensure we release network resources + $stream->close(); + } + } } From e52759cf133940fa3b4e1ecaddfd7a4492d1dce9 Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 13 Aug 2025 18:23:09 +0800 Subject: [PATCH 05/10] Lint --- src/Endpoints/ChatWorkspaces.php | 1 - src/Http/Client.php | 4 ++-- tests/Endpoints/ChatWorkspaceTest.php | 29 +++++++++++++-------------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/Endpoints/ChatWorkspaces.php b/src/Endpoints/ChatWorkspaces.php index 3847fdec..3bc9318f 100644 --- a/src/Endpoints/ChatWorkspaces.php +++ b/src/Endpoints/ChatWorkspaces.php @@ -5,7 +5,6 @@ namespace Meilisearch\Endpoints; use Meilisearch\Contracts\ChatWorkspacesResults; -use Meilisearch\Contracts\ChatWorkspaceSettings; use Meilisearch\Contracts\Endpoint; use Meilisearch\Contracts\Http; use Meilisearch\Endpoints\Delegates\HandlesChatWorkspaceSettings; diff --git a/src/Http/Client.php b/src/Http/Client.php index 87a0019d..61bec266 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -199,10 +199,10 @@ private function executeStream(RequestInterface $request, array $headers = []): try { $body = $this->json->unserialize($bodyContent) ?? $response->getReasonPhrase(); } catch (\JsonException $e) { - $body = $bodyContent ?: $response->getReasonPhrase(); + $body = $bodyContent !== '' ? $bodyContent : $response->getReasonPhrase(); } } else { - $body = $bodyContent ?: $response->getReasonPhrase(); + $body = $bodyContent !== '' ? $bodyContent : $response->getReasonPhrase(); } throw new ApiException($response, $body); diff --git a/tests/Endpoints/ChatWorkspaceTest.php b/tests/Endpoints/ChatWorkspaceTest.php index 66b1b9ea..5a3d1b30 100644 --- a/tests/Endpoints/ChatWorkspaceTest.php +++ b/tests/Endpoints/ChatWorkspaceTest.php @@ -4,23 +4,22 @@ namespace Tests\Endpoints; -use Meilisearch\Client as MeilisearchClient; use Meilisearch\Http\Client; use Tests\TestCase; final class ChatWorkspaceTest extends TestCase { private array $workspaceSettings = [ - "source" => "openAi", - "orgId" => "some-org-id", - "projectId" => "some-project-id", - "apiVersion" => "some-api-version", - "deploymentId" => "some-deployment-id", - "baseUrl" => "https://baseurl.com", - "apiKey" => "sk-abc...", - "prompts" => [ - "system" => "You are a helpful assistant that answers questions based on the provided context.", - ], + 'source' => 'openAi', + 'orgId' => 'some-org-id', + 'projectId' => 'some-project-id', + 'apiVersion' => 'some-api-version', + 'deploymentId' => 'some-deployment-id', + 'baseUrl' => 'https://baseurl.com', + 'apiKey' => 'sk-abc...', + 'prompts' => [ + 'system' => 'You are a helpful assistant that answers questions based on the provided context.', + ], ]; protected function setUp(): void @@ -66,7 +65,7 @@ public function testListWorkspaces(): void $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); $response = $this->client->chats->listWorkspaces(); self::assertSame([ - ['uid' => 'myWorkspace'], + ['uid' => 'myWorkspace'], ], $response->getResults()); } @@ -85,7 +84,7 @@ public function testDeleteWorkspaceSettings(): void $listResponse = $this->client->chats->listWorkspaces(); self::assertSame([ - ['uid' => 'myWorkspace'], + ['uid' => 'myWorkspace'], ], $listResponse->getResults()); } @@ -117,14 +116,14 @@ public function testCompletionStreaming(): void continue; } $receivedData .= $chunk; - $chunkCount++; + ++$chunkCount; } if ($chunkCount >= $maxChunks) { self::fail('Test exceeded maximum chunk limit of '.$maxChunks); } - self::assertGreaterThan(0, strlen($receivedData)); + self::assertGreaterThan(0, \strlen($receivedData)); } finally { // Ensure we release network resources $stream->close(); From 1395b8dba6587b1e28560202a176ed035907cfe3 Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 13 Aug 2025 18:35:05 +0800 Subject: [PATCH 06/10] Run phpstan --- src/Http/Client.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Client.php b/src/Http/Client.php index 61bec266..74647adc 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -199,10 +199,10 @@ private function executeStream(RequestInterface $request, array $headers = []): try { $body = $this->json->unserialize($bodyContent) ?? $response->getReasonPhrase(); } catch (\JsonException $e) { - $body = $bodyContent !== '' ? $bodyContent : $response->getReasonPhrase(); + $body = '' !== $bodyContent ? $bodyContent : $response->getReasonPhrase(); } } else { - $body = $bodyContent !== '' ? $bodyContent : $response->getReasonPhrase(); + $body = '' !== $bodyContent ? $bodyContent : $response->getReasonPhrase(); } throw new ApiException($response, $body); From f87bde49dc37288f94a4e52ffdca90d70f0955c1 Mon Sep 17 00:00:00 2001 From: Strift Date: Wed, 13 Aug 2025 18:44:40 +0800 Subject: [PATCH 07/10] Fix doc types --- .../Delegates/HandlesChatWorkspaceSettings.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php index 80b6f05d..9a7d3a64 100644 --- a/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php +++ b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php @@ -33,12 +33,7 @@ public function getSettings(): ChatWorkspaceSettings * deploymentId?: string, * baseUrl?: string, * apiKey?: string, - * prompts?: array{ - * system?: string, - * searchDescription?: string, - * searchQParam?: string, - * searchIndexUidParam?: string - * } + * prompts?: array * } $settings */ public function updateSettings(array $settings): ChatWorkspaceSettings @@ -67,15 +62,13 @@ public function resetSettings(): ChatWorkspaceSettings } /** - * Create a streaming chat completion. + * Create a streaming chat completion using OpenAI-compatible API. * * @param array{ * model: string, * messages: array, - * stream: bool, - * temperature?: float, - * max_tokens?: int - * } $options + * stream: bool + * } $options The request body for the chat completion */ public function streamCompletion(array $options): \Psr\Http\Message\StreamInterface { From b063d4bfc004c410a691ea24213068c6890cc982 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 15 Aug 2025 12:12:41 +0800 Subject: [PATCH 08/10] Add get/update methods for chat settings --- src/Endpoints/Delegates/HandlesSettings.php | 97 ++++++++++++++++++++- tests/Settings/ChatTest.php | 64 ++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/Settings/ChatTest.php diff --git a/src/Endpoints/Delegates/HandlesSettings.php b/src/Endpoints/Delegates/HandlesSettings.php index da392df7..aeecda93 100644 --- a/src/Endpoints/Delegates/HandlesSettings.php +++ b/src/Endpoints/Delegates/HandlesSettings.php @@ -418,18 +418,27 @@ public function resetSearchCutoffMs(): array return $this->http->delete(self::PATH.'/'.$this->uid.'/settings/search-cutoff-ms'); } - // Settings - Experimental: Embedders (hybrid search) + // Settings - Embedders + /** + * @since Meilisearch v1.13.0 + */ public function getEmbedders(): ?array { return $this->http->get(self::PATH.'/'.$this->uid.'/settings/embedders'); } + /** + * @since Meilisearch v1.13.0 + */ public function updateEmbedders(array $embedders): array { return $this->http->patch(self::PATH.'/'.$this->uid.'/settings/embedders', $embedders); } + /** + * @since Meilisearch v1.13.0 + */ public function resetEmbedders(): array { return $this->http->delete(self::PATH.'/'.$this->uid.'/settings/embedders'); @@ -490,4 +499,90 @@ public function resetPrefixSearch(): array { return $this->http->delete(self::PATH.'/'.$this->uid.'/settings/prefix-search'); } + + // Settings - Chat + + /** + * @since Meilisearch v1.15.1 + * @return array{ + * description: string, + * documentTemplate: string, + * documentTemplateMaxBytes: int, + * searchParameters: array{ + * indexUid?: non-empty-string, + * q?: string, + * filter?: list>, + * locales?: list, + * attributesToRetrieve?: list, + * attributesToCrop?: list, + * cropLength?: positive-int, + * attributesToHighlight?: list, + * cropMarker?: string, + * highlightPreTag?: string, + * highlightPostTag?: string, + * facets?: list, + * showMatchesPosition?: bool, + * sort?: list, + * matchingStrategy?: 'last'|'all'|'frequency', + * offset?: non-negative-int, + * limit?: non-negative-int, + * hitsPerPage?: non-negative-int, + * page?: non-negative-int, + * vector?: non-empty-list>, + * hybrid?: array, + * attributesToSearchOn?: non-empty-list, + * showRankingScore?: bool, + * showRankingScoreDetails?: bool, + * rankingScoreThreshold?: float, + * distinct?: non-empty-string, + * federationOptions?: array + * } + * } + */ + public function getChat(): array + { + return $this->http->get(self::PATH.'/'.$this->uid.'/settings/chat'); + } + + /** + * @since Meilisearch v1.15.1 + * @param array{ + * description: string, + * documentTemplate: string, + * documentTemplateMaxBytes: int, + * searchParameters: array{ + * indexUid?: non-empty-string, + * q?: string, + * filter?: list>, + * locales?: list, + * attributesToRetrieve?: list, + * attributesToCrop?: list, + * cropLength?: positive-int, + * attributesToHighlight?: list, + * cropMarker?: string, + * highlightPreTag?: string, + * highlightPostTag?: string, + * facets?: list, + * showMatchesPosition?: bool, + * sort?: list, + * matchingStrategy?: 'last'|'all'|'frequency', + * offset?: non-negative-int, + * limit?: non-negative-int, + * hitsPerPage?: non-negative-int, + * page?: non-negative-int, + * vector?: non-empty-list>, + * hybrid?: array, + * attributesToSearchOn?: non-empty-list, + * showRankingScore?: bool, + * showRankingScoreDetails?: bool, + * rankingScoreThreshold?: float, + * distinct?: non-empty-string, + * federationOptions?: array + * } + * } $chatSettings + */ + public function updateChat(array $chatSettings): array + { + return $this->http->patch(self::PATH.'/'.$this->uid.'/settings/chat', $chatSettings); + } } diff --git a/tests/Settings/ChatTest.php b/tests/Settings/ChatTest.php new file mode 100644 index 00000000..8c163572 --- /dev/null +++ b/tests/Settings/ChatTest.php @@ -0,0 +1,64 @@ + '', + 'documentTemplate' => '{% for field in fields %}{% if field.is_searchable and field.value != nil %}{{ field.name }}: {{ field.value }} +{% endif %}{% endfor %}', + 'documentTemplateMaxBytes' => 400, + 'searchParameters' => [] + ]; + + protected function setUp(): void + { + parent::setUp(); + + $http = new Client($this->host, getenv('MEILISEARCH_API_KEY')); + $http->patch('/experimental-features', ['chatCompletions' => true]); + + $this->index = $this->createEmptyIndex($this->safeIndexName()); + } + + public function testGetChatDefaultSettings(): void + { + $settings = $this->index->getChat(); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['description'], $settings['description']); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['documentTemplate'], $settings['documentTemplate']); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['documentTemplateMaxBytes'], $settings['documentTemplateMaxBytes']); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['searchParameters'], $settings['searchParameters']); + } + + public function testUpdateChatSettings(): void + { + $newSettings = [ + 'description' => 'New description', + 'documentTemplate' => 'New document template', + 'documentTemplateMaxBytes' => 500, + 'searchParameters' => [ + 'limit' => 10, + ], + ]; + + $promise = $this->index->updateChat($newSettings); + + $this->assertIsValidPromise($promise); + $this->index->waitForTask($promise['taskUid']); + + $settings = $this->index->getChat(); + self::assertSame($newSettings['description'], $settings['description']); + self::assertSame($newSettings['documentTemplate'], $settings['documentTemplate']); + self::assertSame($newSettings['documentTemplateMaxBytes'], $settings['documentTemplateMaxBytes']); + self::assertSame($newSettings['searchParameters'], $settings['searchParameters']); + } +} From b09585f0a819cfe47b60309fd63adc75a28773de Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 15 Aug 2025 13:06:28 +0800 Subject: [PATCH 09/10] Fix incorrect exception catch block --- src/Http/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Client.php b/src/Http/Client.php index 74647adc..abf0acbc 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -198,7 +198,7 @@ private function executeStream(RequestInterface $request, array $headers = []): if ($this->isJSONResponse($response->getHeader('content-type'))) { try { $body = $this->json->unserialize($bodyContent) ?? $response->getReasonPhrase(); - } catch (\JsonException $e) { + } catch (JsonDecodingException $e) { $body = '' !== $bodyContent ? $bodyContent : $response->getReasonPhrase(); } } else { From 0cc39727766a7459dc7905fe8d4938cefbcb458f Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 15 Aug 2025 13:12:26 +0800 Subject: [PATCH 10/10] lint --- src/Endpoints/Delegates/HandlesSettings.php | 2 + tests/Settings/ChatTest.php | 50 ++++++++++----------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/Endpoints/Delegates/HandlesSettings.php b/src/Endpoints/Delegates/HandlesSettings.php index aeecda93..203e84af 100644 --- a/src/Endpoints/Delegates/HandlesSettings.php +++ b/src/Endpoints/Delegates/HandlesSettings.php @@ -504,6 +504,7 @@ public function resetPrefixSearch(): array /** * @since Meilisearch v1.15.1 + * * @return array{ * description: string, * documentTemplate: string, @@ -546,6 +547,7 @@ public function getChat(): array /** * @since Meilisearch v1.15.1 + * * @param array{ * description: string, * documentTemplate: string, diff --git a/tests/Settings/ChatTest.php b/tests/Settings/ChatTest.php index 8c163572..5bd23395 100644 --- a/tests/Settings/ChatTest.php +++ b/tests/Settings/ChatTest.php @@ -13,11 +13,11 @@ final class ChatTest extends TestCase private Indexes $index; private const DEFAULT_CHAT_SETTINGS = [ - 'description' => '', - 'documentTemplate' => '{% for field in fields %}{% if field.is_searchable and field.value != nil %}{{ field.name }}: {{ field.value }} + 'description' => '', + 'documentTemplate' => '{% for field in fields %}{% if field.is_searchable and field.value != nil %}{{ field.name }}: {{ field.value }} {% endif %}{% endfor %}', - 'documentTemplateMaxBytes' => 400, - 'searchParameters' => [] + 'documentTemplateMaxBytes' => 400, + 'searchParameters' => [], ]; protected function setUp(): void @@ -32,33 +32,33 @@ protected function setUp(): void public function testGetChatDefaultSettings(): void { - $settings = $this->index->getChat(); - self::assertSame(self::DEFAULT_CHAT_SETTINGS['description'], $settings['description']); - self::assertSame(self::DEFAULT_CHAT_SETTINGS['documentTemplate'], $settings['documentTemplate']); - self::assertSame(self::DEFAULT_CHAT_SETTINGS['documentTemplateMaxBytes'], $settings['documentTemplateMaxBytes']); - self::assertSame(self::DEFAULT_CHAT_SETTINGS['searchParameters'], $settings['searchParameters']); + $settings = $this->index->getChat(); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['description'], $settings['description']); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['documentTemplate'], $settings['documentTemplate']); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['documentTemplateMaxBytes'], $settings['documentTemplateMaxBytes']); + self::assertSame(self::DEFAULT_CHAT_SETTINGS['searchParameters'], $settings['searchParameters']); } public function testUpdateChatSettings(): void { - $newSettings = [ - 'description' => 'New description', - 'documentTemplate' => 'New document template', - 'documentTemplateMaxBytes' => 500, - 'searchParameters' => [ - 'limit' => 10, - ], - ]; + $newSettings = [ + 'description' => 'New description', + 'documentTemplate' => 'New document template', + 'documentTemplateMaxBytes' => 500, + 'searchParameters' => [ + 'limit' => 10, + ], + ]; - $promise = $this->index->updateChat($newSettings); + $promise = $this->index->updateChat($newSettings); - $this->assertIsValidPromise($promise); - $this->index->waitForTask($promise['taskUid']); + $this->assertIsValidPromise($promise); + $this->index->waitForTask($promise['taskUid']); - $settings = $this->index->getChat(); - self::assertSame($newSettings['description'], $settings['description']); - self::assertSame($newSettings['documentTemplate'], $settings['documentTemplate']); - self::assertSame($newSettings['documentTemplateMaxBytes'], $settings['documentTemplateMaxBytes']); - self::assertSame($newSettings['searchParameters'], $settings['searchParameters']); + $settings = $this->index->getChat(); + self::assertSame($newSettings['description'], $settings['description']); + self::assertSame($newSettings['documentTemplate'], $settings['documentTemplate']); + self::assertSame($newSettings['documentTemplateMaxBytes'], $settings['documentTemplateMaxBytes']); + self::assertSame($newSettings['searchParameters'], $settings['searchParameters']); } }