diff --git a/examples/.env b/examples/.env index 132a950fb..7362033b6 100644 --- a/examples/.env +++ b/examples/.env @@ -83,31 +83,36 @@ ALBERT_API_URL= # For MariaDB store. Server defined in compose.yaml MARIADB_URI=pdo-mysql://root@127.0.0.1:3309/my_database -# Meilisearch +# Meilisearch (store) MEILISEARCH_HOST=http://127.0.0.1:7700 MEILISEARCH_API_KEY=changeMe # For using LMStudio LMSTUDIO_HOST_URL=http://127.0.0.1:1234 -# Qdrant +# Qdrant (store) QDRANT_HOST=http://127.0.0.1:6333 QDRANT_SERVICE_API_KEY=changeMe -# SurrealDB +# SurrealDB (store) SURREALDB_HOST=http://127.0.0.1:8000 SURREALDB_USER=symfony SURREALDB_PASS=symfony -# Neo4J +# Neo4J (store) NEO4J_HOST=http://127.0.0.1:7474 NEO4J_DATABASE=neo4j NEO4J_USERNAME=neo4j NEO4J_PASSWORD=symfonyai -# Typesense +# Typesense (store) TYPESENSE_HOST=http://127.0.0.1:8108 TYPESENSE_API_KEY=changeMe +# Milvus (store) +MILVUS_HOST=http://127.0.0.1:19530 +MILVUS_API_KEY=root:Milvus +MILVUS_DATABASE=symfony + # Cerebras CEREBRAS_API_KEY= diff --git a/examples/compose.yaml b/examples/compose.yaml index 7be0538db..4815b2e39 100644 --- a/examples/compose.yaml +++ b/examples/compose.yaml @@ -56,5 +56,69 @@ services: ports: - '8108:8108' + # Milvus services + etcd: + container_name: milvus-etcd + image: quay.io/coreos/etcd:v3.5.18 + environment: + - ETCD_AUTO_COMPACTION_MODE=revision + - ETCD_AUTO_COMPACTION_RETENTION=1000 + - ETCD_QUOTA_BACKEND_BYTES=4294967296 + - ETCD_SNAPSHOT_COUNT=50000 + volumes: + - etcd_vlm:/etcd + command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd + healthcheck: + test: [ "CMD", "etcdctl", "endpoint", "health" ] + interval: 30s + timeout: 20s + retries: 3 + + minio: + container_name: milvus-minio + image: minio/minio:RELEASE.2024-12-18T13-15-44Z + environment: + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + ports: + - '9001:9001' + - '9000:9000' + volumes: + - minio_vlm:/minio_data + command: minio server /minio_data --console-address ":9001" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] + interval: 30s + timeout: 20s + retries: 3 + + milvus: + container_name: milvus-standalone + image: milvusdb/milvus:v2.6.0 + command: ["milvus", "run", "standalone"] + security_opt: + - seccomp:unconfined + environment: + ETCD_ENDPOINTS: etcd:2379 + MINIO_ADDRESS: minio:9000 + MQ_TYPE: woodpecker + volumes: + - milvus_vlm:/var/lib/milvus + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] + interval: 30s + start_period: 90s + timeout: 20s + retries: 3 + ports: + - '19530:19530' + - '9091:9091' + depends_on: + - 'etcd' + - 'minio' + volumes: typesense_data: + etcd_vlm: + minio_vlm: + milvus_vlm: diff --git a/examples/rag/milvus.php b/examples/rag/milvus.php new file mode 100644 index 000000000..b1bb75d09 --- /dev/null +++ b/examples/rag/milvus.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Fixtures\Movies; +use Symfony\AI\Platform\Bridge\OpenAi\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Store\Bridge\Milvus\Store; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\TextDocument; +use Symfony\AI\Store\Document\Vectorizer; +use Symfony\AI\Store\Indexer; +use Symfony\Component\Uid\Uuid; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// initialize the store +$store = new Store( + httpClient: http_client(), + endpointUrl: env('MILVUS_HOST'), + apiKey: env('MILVUS_API_KEY'), + database: env('MILVUS_DATABASE'), + collection: 'movies', +); + +// initialize the index +$store->initialize(); + +// create embeddings and documents +$documents = []; +foreach (Movies::all() as $i => $movie) { + $documents[] = new TextDocument( + id: Uuid::v4(), + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], + metadata: new Metadata($movie), + ); +} + +// create embeddings for documents +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$vectorizer = new Vectorizer($platform, $embeddings = new Embeddings()); +$indexer = new Indexer($vectorizer, $store, logger()); +$indexer->index($documents); + +$model = new Gpt(Gpt::GPT_4O_MINI); + +$similaritySearch = new SimilaritySearch($platform, $embeddings, $store); +$toolbox = new Toolbox([$similaritySearch], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger()); + +$messages = new MessageBag( + Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), + Message::ofUser('Which movie fits the theme of technology?') +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 0eed2bd6e..2b5c38cdd 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -227,6 +227,21 @@ ->end() ->end() ->end() + ->arrayNode('milvus') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('endpoint')->cannotBeEmpty()->end() + ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('database')->isRequired()->end() + ->scalarNode('collection')->isRequired()->end() + ->scalarNode('vector_field')->end() + ->scalarNode('dimensions')->end() + ->scalarNode('metric_type')->end() + ->end() + ->end() + ->end() ->arrayNode('mongodb') ->normalizeKeys(false) ->useAttributeAsKey('name') diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 71e683cb7..3f4052ecd 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -48,6 +48,7 @@ use Symfony\AI\Store\Bridge\Local\DistanceStrategy; use Symfony\AI\Store\Bridge\Local\InMemoryStore; use Symfony\AI\Store\Bridge\Meilisearch\Store as MeilisearchStore; +use Symfony\AI\Store\Bridge\Milvus\Store as MilvusStore; use Symfony\AI\Store\Bridge\MongoDb\Store as MongoDbStore; use Symfony\AI\Store\Bridge\Neo4j\Store as Neo4jStore; use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore; @@ -634,6 +635,37 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde } } + if ('milvus' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + new Reference('http_client'), + $store['endpoint'], + $store['api_key'], + $store['database'], + $store['collection'], + ]; + + if (\array_key_exists('vector_field', $store)) { + $arguments[5] = $store['vector_field']; + } + + if (\array_key_exists('dimensions', $store)) { + $arguments[6] = $store['dimensions']; + } + + if (\array_key_exists('metric_type', $store)) { + $arguments[7] = $store['metric_type']; + } + + $definition = new Definition(MilvusStore::class); + $definition + ->addTag('ai.store') + ->setArguments($arguments); + + $container->setDefinition('ai.store.'.$type.'.'.$name, $definition); + } + } + if ('mongodb' === $type) { foreach ($stores as $name => $store) { $arguments = [ diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 00cd0ded2..fc1873a2b 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -393,6 +393,17 @@ private function getFullConfig(): array 'strategy' => 'cosine', ], ], + 'milvus' => [ + 'my_milvus_store' => [ + 'endpoint' => 'http://127.0.0.1:19530', + 'api_key' => 'foo', + 'database' => 'test', + 'collection' => 'default', + 'vector_field' => '_vectors', + 'dimensions' => 768, + 'metric_type' => 'COSINE', + ], + ], 'mongodb' => [ 'my_mongo_store' => [ 'database' => 'my_db', diff --git a/src/store/doc/index.rst b/src/store/doc/index.rst index f0887997a..45c9eac1f 100644 --- a/src/store/doc/index.rst +++ b/src/store/doc/index.rst @@ -40,6 +40,7 @@ You can find more advanced usage in combination with an Agent using the store fo * `Similarity Search with MariaDB (RAG)`_ * `Similarity Search with Meilisearch (RAG)`_ * `Similarity Search with memory storage (RAG)`_ +* `Similarity Search with Milvus (RAG)`_ * `Similarity Search with MongoDB (RAG)`_ * `Similarity Search with Neo4j (RAG)`_ * `Similarity Search with Pinecone (RAG)`_ @@ -62,6 +63,7 @@ Supported Stores * `InMemory`_ * `MariaDB`_ (requires `ext-pdo`) * `Meilisearch`_ +* `Milvus`_ * `MongoDB Atlas`_ (requires `mongodb/mongodb` as additional dependency) * `Neo4j`_ * `Pinecone`_ (requires `probots-io/pinecone-php` as additional dependency) @@ -101,9 +103,10 @@ This leads to a store implementing two methods:: .. _`Retrieval Augmented Generation`: https://de.wikipedia.org/wiki/Retrieval-Augmented_Generation .. _`Similarity Search with MariaDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/mariadb-gemini.php -.. _`Similarity Search with MongoDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/mongodb.php .. _`Similarity Search with Meilisearch (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/meilisearch.php .. _`Similarity Search with memory storage (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/in-memory.php +.. _`Similarity Search with Milvus (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/meilisearch.php +.. _`Similarity Search with MongoDB (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/milvus.php .. _`Similarity Search with Neo4j (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/neo4j.php .. _`Similarity Search with Pinecone (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/pinecone.php .. _`Similarity Search with Symfony Cache (RAG)`: https://github.com/symfony/ai/blob/main/examples/rag/cache.php @@ -113,10 +116,11 @@ This leads to a store implementing two methods:: .. _`Azure AI Search`: https://azure.microsoft.com/products/ai-services/ai-search .. _`Chroma`: https://www.trychroma.com/ .. _`MariaDB`: https://mariadb.org/projects/mariadb-vector/ -.. _`MongoDB Atlas`: https://www.mongodb.com/atlas .. _`Pinecone`: https://www.pinecone.io/ .. _`Postgres`: https://www.postgresql.org/about/news/pgvector-070-released-2852/ .. _`Meilisearch`: https://www.meilisearch.com/ +.. _`Milvus`: https://milvus.io/ +.. _`MongoDB Atlas`: https://www.mongodb.com/atlas .. _`SurrealDB`: https://surrealdb.com/ .. _`InMemory`: https://www.php.net/manual/en/language.types.array.php .. _`Qdrant`: https://qdrant.tech/ diff --git a/src/store/src/Bridge/Milvus/Store.php b/src/store/src/Bridge/Milvus/Store.php new file mode 100644 index 000000000..018f39a37 --- /dev/null +++ b/src/store/src/Bridge/Milvus/Store.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Milvus; + +use Symfony\AI\Platform\Vector\NullVector; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\AI\Store\InitializableStoreInterface; +use Symfony\AI\Store\StoreInterface; +use Symfony\Component\Uid\Uuid; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class Store implements InitializableStoreInterface, StoreInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $endpointUrl, + #[\SensitiveParameter] private string $apiKey, + private string $database, + private string $collection, + private string $vectorFieldName = '_vectors', + private int $dimensions = 1536, + private string $metricType = 'COSINE', + ) { + } + + public function add(VectorDocument ...$documents): void + { + $this->request('POST', 'v2/vectordb/entities/insert', [ + 'collectionName' => $this->collection, + 'data' => array_map($this->convertToIndexableArray(...), $documents), + ]); + } + + public function query(Vector $vector, array $options = []): array + { + $payload = [ + 'collectionName' => $this->collection, + 'data' => [ + $vector->getData(), + ], + 'annsField' => $this->vectorFieldName, + 'outputFields' => ['id', '_metadata', $this->vectorFieldName], + ]; + + if (isset($options['limit'])) { + $payload['limit'] = $options['limit']; + } + + $documents = $this->request('POST', 'v2/vectordb/entities/search', $payload); + + return array_map($this->convertToVectorDocument(...), $documents['data']); + } + + /** + * @param array{ + * forceDatabaseCreation?: bool, + * } $options + */ + public function initialize(array $options = []): void + { + if (\array_key_exists('forceDatabaseCreation', $options) && $options['forceDatabaseCreation']) { + $this->request('POST', 'v2/vectordb/databases/create', [ + 'dbName' => $this->database, + ]); + } + + $this->request('POST', 'v2/vectordb/collections/create', [ + 'collectionName' => $this->collection, + 'schema' => [ + 'autoId' => false, + 'enableDynamicField' => false, + 'fields' => [ + [ + 'fieldName' => 'id', + 'dataType' => 'VarChar', + 'isPrimary' => true, + 'elementTypeParams' => [ + 'max_length' => '512', + ], + ], + [ + 'fieldName' => '_metadata', + 'dataType' => 'VarChar', + 'elementTypeParams' => [ + 'max_length' => '512', + ], + ], + [ + 'fieldName' => $this->vectorFieldName, + 'dataType' => 'FloatVector', + 'elementTypeParams' => [ + 'dim' => $this->dimensions, + ], + ], + ], + ], + 'indexParams' => [ + [ + 'fieldName' => $this->vectorFieldName, + 'metricType' => $this->metricType, + 'indexName' => \sprintf('%s_%s', $this->collection, $this->vectorFieldName), + 'indexType' => 'AUTOINDEX', + ], + ], + ]); + } + + /** + * @param array $payload + * + * @return array + */ + private function request(string $method, string $endpoint, array $payload): array + { + $url = \sprintf('%s/%s', $this->endpointUrl, $endpoint); + $result = $this->httpClient->request($method, $url, [ + 'auth_bearer' => $this->apiKey, + 'json' => $payload, + ]); + + return $result->toArray(); + } + + /** + * @return array + */ + private function convertToIndexableArray(VectorDocument $document): array + { + return [ + 'id' => $document->id->toRfc4122(), + '_metadata' => json_encode($document->metadata->getArrayCopy()), + $this->vectorFieldName => $document->vector->getData(), + ]; + } + + /** + * @param array $data + */ + private function convertToVectorDocument(array $data): VectorDocument + { + $id = $data['id'] ?? throw new InvalidArgumentException('Missing "id" field in the document data.'); + + $vector = !\array_key_exists($this->vectorFieldName, $data) || null === $data[$this->vectorFieldName] + ? new NullVector() : new Vector($data[$this->vectorFieldName]); + + $score = $data['distance'] ?? null; + + return new VectorDocument(Uuid::fromString($id), $vector, new Metadata(json_decode($data['_metadata'], true)), $score); + } +} diff --git a/src/store/src/DroppableStoreInterface.php b/src/store/src/DroppableStoreInterface.php new file mode 100644 index 000000000..95c48dd4b --- /dev/null +++ b/src/store/src/DroppableStoreInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store; + +/** + * @author Guillaume Loulier + */ +interface DroppableStoreInterface +{ + public function drop(): void; +} diff --git a/src/store/tests/Bridge/Meilisearch/StoreTest.php b/src/store/tests/Bridge/Meilisearch/StoreTest.php index 990825714..485accc49 100644 --- a/src/store/tests/Bridge/Meilisearch/StoreTest.php +++ b/src/store/tests/Bridge/Meilisearch/StoreTest.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Store\Tests\Bridge\Meilisearch; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Vector\Vector; use Symfony\AI\Store\Bridge\Meilisearch\Store; @@ -22,6 +23,8 @@ use Symfony\Component\Uid\Uuid; #[CoversClass(Store::class)] +#[UsesClass(VectorDocument::class)] +#[UsesClass(Vector::class)] final class StoreTest extends TestCase { public function testStoreCannotInitializeOnInvalidResponse() diff --git a/src/store/tests/Bridge/Milvus/StoreTest.php b/src/store/tests/Bridge/Milvus/StoreTest.php new file mode 100644 index 000000000..b2dbd7050 --- /dev/null +++ b/src/store/tests/Bridge/Milvus/StoreTest.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Bridge\Milvus; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Bridge\Milvus\Store; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Uid\Uuid; + +#[CoversClass(Store::class)] +#[UsesClass(VectorDocument::class)] +#[UsesClass(Vector::class)] +final class StoreTest extends TestCase +{ + public function testStoreCannotInitializeOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ], 'http://127.0.0.1:19530'); + + $store = new Store( + $httpClient, + 'http://127.0.0.1:19530', + 'test', + 'test', + 'test', + ); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:19530/v2/vectordb/databases/create".'); + $this->expectExceptionCode(400); + $store->initialize([ + 'forceDatabaseCreation' => true, + ]); + } + + public function testStoreCanInitialize() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 0, + 'data' => [], + ], [ + 'http_code' => 200, + ]), + new JsonMockResponse([ + 'code' => 0, + 'data' => [], + ], [ + 'http_code' => 200, + ]), + ], 'http://127.0.0.1:19530'); + + $store = new Store( + $httpClient, + 'http://127.0.0.1:19530', + 'test', + 'test', + 'test', + ); + + $store->initialize([ + 'forceDatabaseCreation' => true, + ]); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testStoreCannotAddOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ], 'http://127.0.0.1:19530'); + + $store = new Store( + $httpClient, + 'http://127.0.0.1:19530', + 'test', + 'test', + 'test', + ); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:19530/v2/vectordb/entities/insert".'); + $this->expectExceptionCode(400); + $store->add(new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2, 0.3]))); + } + + public function testStoreCanAdd() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 0, + 'cost' => 0, + 'data' => [ + 'insertCount' => 1, + 'insertIds' => [ + Uuid::v4()->toRfc4122(), + ], + ], + ], [ + 'http_code' => 200, + ]), + ], 'http://127.0.0.1:19530'); + + $store = new Store( + $httpClient, + 'http://127.0.0.1:19530', + 'test', + 'test', + 'test', + ); + + $store->add(new VectorDocument(Uuid::v4(), new Vector([0.1, 0.2, 0.3]))); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotQueryOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([], [ + 'http_code' => 400, + ]), + ], 'http://127.0.0.1:19530'); + + $store = new Store( + $httpClient, + 'http://127.0.0.1:19530', + 'test', + 'test', + 'test', + ); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('HTTP 400 returned for "http://127.0.0.1:19530/v2/vectordb/entities/search".'); + $this->expectExceptionCode(400); + $store->query(new Vector([0.1, 0.2, 0.3])); + } + + public function testStoreCanQuery() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'code' => 0, + 'cost' => 0, + 'data' => [ + [ + 'id' => Uuid::v4()->toRfc4122(), + '_vectors' => [0.1, 0.2, 0.3], + '_metadata' => '{"foo":"bar"}', + 'distance' => 1.0, + ], + [ + 'id' => Uuid::v4()->toRfc4122(), + '_vectors' => [0.11, 0.22, 0.33], + '_metadata' => '{"foo":"bar"}', + 'distance' => 0.8, + ], + ], + ], [ + 'http_code' => 200, + ]), + ], 'http://127.0.0.1:19530'); + + $store = new Store( + $httpClient, + 'http://127.0.0.1:19530', + 'test', + 'test', + 'test', + ); + + $results = $store->query(new Vector([0.1, 0.2, 0.3])); + + $this->assertCount(2, $results); + $this->assertSame(1, $httpClient->getRequestsCount()); + } +}