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/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/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/ChatWorkspaces.php b/src/Endpoints/ChatWorkspaces.php new file mode 100644 index 00000000..3bc9318f --- /dev/null +++ b/src/Endpoints/ChatWorkspaces.php @@ -0,0 +1,37 @@ +workspaceName = $workspaceName; + parent::__construct($http); + } + + public function listWorkspaces(): ChatWorkspacesResults + { + $response = $this->http->get(self::PATH); + + return new ChatWorkspacesResults($response); + } + + public function workspace(string $workspaceName): self + { + return new self($this->http, $workspaceName); + } +} diff --git a/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php new file mode 100644 index 00000000..9a7d3a64 --- /dev/null +++ b/src/Endpoints/Delegates/HandlesChatWorkspaceSettings.php @@ -0,0 +1,81 @@ +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 + * } $settings + */ + public function updateSettings(array $settings): ChatWorkspaceSettings + { + if (null === $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 (null === $this->workspaceName) { + throw new \InvalidArgumentException('Workspace name is required to reset settings'); + } + + $response = $this->http->delete('/chats/'.$this->workspaceName.'/settings'); + + return new ChatWorkspaceSettings($response); + } + + /** + * Create a streaming chat completion using OpenAI-compatible API. + * + * @param array{ + * model: string, + * messages: array, + * stream: bool + * } $options The request body for the chat completion + */ + 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/Endpoints/Delegates/HandlesChatWorkspaces.php b/src/Endpoints/Delegates/HandlesChatWorkspaces.php new file mode 100644 index 00000000..343a6a51 --- /dev/null +++ b/src/Endpoints/Delegates/HandlesChatWorkspaces.php @@ -0,0 +1,29 @@ +chats->listWorkspaces(); + } + + /** + * Get a specific chat workspace instance. + */ + public function chatWorkspace(string $workspaceName): ChatWorkspaces + { + return $this->chats->workspace($workspaceName); + } +} diff --git a/src/Endpoints/Delegates/HandlesSettings.php b/src/Endpoints/Delegates/HandlesSettings.php index da392df7..203e84af 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,92 @@ 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/src/Http/Client.php b/src/Http/Client.php index 9eef6472..abf0acbc 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 (JsonDecodingException $e) { + $body = '' !== $bodyContent ? $bodyContent : $response->getReasonPhrase(); + } + } else { + $body = '' !== $bodyContent ? $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/ChatWorkspaceTest.php b/tests/Endpoints/ChatWorkspaceTest.php new file mode 100644 index 00000000..5a3d1b30 --- /dev/null +++ b/tests/Endpoints/ChatWorkspaceTest.php @@ -0,0 +1,132 @@ + '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->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->getApiKey()); + } + + 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->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->getApiKey()); + } + + public function testListWorkspaces(): void + { + $this->client->chats->workspace('myWorkspace')->updateSettings($this->workspaceSettings); + $response = $this->client->chats->listWorkspaces(); + self::assertSame([ + ['uid' => 'myWorkspace'], + ], $response->getResults()); + } + + 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->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->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(); + } + } +} diff --git a/tests/Settings/ChatTest.php b/tests/Settings/ChatTest.php new file mode 100644 index 00000000..5bd23395 --- /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']); + } +}