diff --git a/.env b/.env index 22048c1c..6ece746a 100644 --- a/.env +++ b/.env @@ -65,5 +65,9 @@ RUN_EXPENSIVE_EXAMPLES=false # For using Gemini GEMINI_API_KEY= +# For using Albert API (French Sovereign AI) +ALBERT_API_KEY= +ALBERT_API_URL= + # For MariaDB store. Server defined in compose.yaml MARIADB_URI=pdo-mysql://root@127.0.0.1:3309/my_database diff --git a/README.md b/README.md index febff538..654771c8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ $embeddings = new Embeddings(); * [DeepSeek's R1](https://www.deepseek.com/) with [OpenRouter](https://www.openrouter.com/) as Platform * [Amazon's Nova](https://nova.amazon.com) with [AWS](https://aws.amazon.com/bedrock/) as Platform * [Mistral's Mistral](https://www.mistral.ai/) with [Mistral](https://www.mistral.ai/) as Platform + * [Albert API](https://github.com/etalab-ia/albert-api) models with [Albert](https://github.com/etalab-ia/albert-api) as Platform (French government's sovereign AI gateway) * Embeddings Models * [OpenAI's Text Embeddings](https://platform.openai.com/docs/guides/embeddings/embedding-models) with [OpenAI](https://platform.openai.com/docs/overview) and [Azure](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) as Platform * [Voyage's Embeddings](https://docs.voyageai.com/docs/embeddings) with [Voyage](https://www.voyageai.com/) as Platform @@ -166,6 +167,7 @@ $response = $chain->call($messages, [ 1. [Google's Gemini with Google](examples/google/chat.php) 1. [Google's Gemini with OpenRouter](examples/openrouter/chat-gemini.php) 1. [Mistral's Mistral with Mistral](examples/mistral/chat-mistral.php) +1. [Albert API (French Sovereign AI)](examples/albert/chat.php) ### Tools diff --git a/examples/albert/chat.php b/examples/albert/chat.php new file mode 100644 index 00000000..14932aa9 --- /dev/null +++ b/examples/albert/chat.php @@ -0,0 +1,47 @@ +call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/src/Platform/Bridge/Albert/EmbeddingsModelClient.php b/src/Platform/Bridge/Albert/EmbeddingsModelClient.php new file mode 100644 index 00000000..be4bcb80 --- /dev/null +++ b/src/Platform/Bridge/Albert/EmbeddingsModelClient.php @@ -0,0 +1,40 @@ + + */ +final readonly class EmbeddingsModelClient implements ModelClientInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl, + ) { + '' !== $apiKey || throw new InvalidArgumentException('The API key must not be empty.'); + '' !== $baseUrl || throw new InvalidArgumentException('The base URL must not be empty.'); + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', \sprintf('%s/embeddings', $this->baseUrl), [ + 'auth_bearer' => $this->apiKey, + 'json' => \is_array($payload) ? array_merge($payload, $options) : $payload, + ]); + } +} diff --git a/src/Platform/Bridge/Albert/GPTModelClient.php b/src/Platform/Bridge/Albert/GPTModelClient.php new file mode 100644 index 00000000..d1639aad --- /dev/null +++ b/src/Platform/Bridge/Albert/GPTModelClient.php @@ -0,0 +1,45 @@ + + */ +final readonly class GPTModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + '' !== $apiKey || throw new InvalidArgumentException('The API key must not be empty.'); + '' !== $baseUrl || throw new InvalidArgumentException('The base URL must not be empty.'); + } + + public function supports(Model $model): bool + { + return $model instanceof GPT; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', \sprintf('%s/chat/completions', $this->baseUrl), [ + 'auth_bearer' => $this->apiKey, + 'json' => \is_array($payload) ? array_merge($payload, $options) : $payload, + ]); + } +} diff --git a/src/Platform/Bridge/Albert/PlatformFactory.php b/src/Platform/Bridge/Albert/PlatformFactory.php new file mode 100644 index 00000000..5ee3a662 --- /dev/null +++ b/src/Platform/Bridge/Albert/PlatformFactory.php @@ -0,0 +1,40 @@ + + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] string $apiKey, + string $baseUrl, + ?HttpClientInterface $httpClient = null, + ): Platform { + str_starts_with($baseUrl, 'https://') || throw new InvalidArgumentException('The Albert URL must start with "https://".'); + !str_ends_with($baseUrl, '/') || throw new InvalidArgumentException('The Albert URL must not end with a trailing slash.'); + preg_match('/\/v\d+$/', $baseUrl) || throw new InvalidArgumentException('The Albert URL must include an API version (e.g., /v1, /v2).'); + + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [ + new GPTModelClient($httpClient, $apiKey, $baseUrl), + new EmbeddingsModelClient($httpClient, $apiKey, $baseUrl), + ], + [new GPTResponseConverter(), new EmbeddingsResponseConverter()], + Contract::create(), + ); + } +} diff --git a/tests/Platform/Bridge/Albert/EmbeddingsModelClientTest.php b/tests/Platform/Bridge/Albert/EmbeddingsModelClientTest.php new file mode 100644 index 00000000..e21101a9 --- /dev/null +++ b/tests/Platform/Bridge/Albert/EmbeddingsModelClientTest.php @@ -0,0 +1,181 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API key must not be empty.'); + + new EmbeddingsModelClient( + new MockHttpClient(), + '', + 'https://albert.example.com/' + ); + } + + #[Test] + public function constructorThrowsExceptionForEmptyBaseUrl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The base URL must not be empty.'); + + new EmbeddingsModelClient( + new MockHttpClient(), + 'test-api-key', + '' + ); + } + + #[Test] + public function supportsEmbeddingsModel(): void + { + $client = new EmbeddingsModelClient( + new MockHttpClient(), + 'test-api-key', + 'https://albert.example.com/' + ); + + $embeddingsModel = new Embeddings('text-embedding-ada-002'); + self::assertTrue($client->supports($embeddingsModel)); + } + + #[Test] + public function doesNotSupportNonEmbeddingsModel(): void + { + $client = new EmbeddingsModelClient( + new MockHttpClient(), + 'test-api-key', + 'https://albert.example.com/' + ); + + $gptModel = new GPT('gpt-3.5-turbo'); + self::assertFalse($client->supports($gptModel)); + } + + #[Test] + #[DataProvider('providePayloadToJson')] + public function requestSendsCorrectHttpRequest(array|string $payload, array $options, array|string $expectedJson): void + { + $capturedRequest = null; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedRequest) { + $capturedRequest = ['method' => $method, 'url' => $url, 'options' => $options]; + + return new JsonMockResponse(['data' => []]); + }); + + $client = new EmbeddingsModelClient( + $httpClient, + 'test-api-key', + 'https://albert.example.com/v1' + ); + + $model = new Embeddings('text-embedding-ada-002'); + $response = $client->request($model, $payload, $options); + + self::assertNotNull($capturedRequest); + self::assertSame('POST', $capturedRequest['method']); + self::assertSame('https://albert.example.com/v1/embeddings', $capturedRequest['url']); + self::assertArrayHasKey('normalized_headers', $capturedRequest['options']); + self::assertArrayHasKey('authorization', $capturedRequest['options']['normalized_headers']); + self::assertStringContainsString('Bearer test-api-key', (string) $capturedRequest['options']['normalized_headers']['authorization'][0]); + + // Check JSON body - it might be in 'body' after processing + if (isset($capturedRequest['options']['body'])) { + $actualJson = json_decode($capturedRequest['options']['body'], true); + self::assertEquals($expectedJson, $actualJson); + } else { + self::assertSame($expectedJson, $capturedRequest['options']['json']); + } + } + + public static function providePayloadToJson(): iterable + { + yield 'with array payload and no options' => [ + ['input' => 'test text', 'model' => 'text-embedding-ada-002'], + [], + ['input' => 'test text', 'model' => 'text-embedding-ada-002'], + ]; + + yield 'with string payload and no options' => [ + 'test text', + [], + 'test text', + ]; + + yield 'with array payload and options' => [ + ['input' => 'test text', 'model' => 'text-embedding-ada-002'], + ['dimensions' => 1536], + ['dimensions' => 1536, 'input' => 'test text', 'model' => 'text-embedding-ada-002'], + ]; + + yield 'options override payload values' => [ + ['input' => 'test text', 'model' => 'text-embedding-ada-002'], + ['model' => 'text-embedding-3-small'], + ['model' => 'text-embedding-3-small', 'input' => 'test text'], + ]; + } + + #[Test] + public function requestHandlesBaseUrlWithoutTrailingSlash(): void + { + $capturedUrl = null; + $httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) { + $capturedUrl = $url; + + return new JsonMockResponse(['data' => []]); + }); + + $client = new EmbeddingsModelClient( + $httpClient, + 'test-api-key', + 'https://albert.example.com/v1' + ); + + $model = new Embeddings('text-embedding-ada-002'); + $client->request($model, ['input' => 'test']); + + self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl); + } + + #[Test] + public function requestHandlesBaseUrlWithTrailingSlash(): void + { + $capturedUrl = null; + $httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) { + $capturedUrl = $url; + + return new JsonMockResponse(['data' => []]); + }); + + $client = new EmbeddingsModelClient( + $httpClient, + 'test-api-key', + 'https://albert.example.com/v1' + ); + + $model = new Embeddings('text-embedding-ada-002'); + $client->request($model, ['input' => 'test']); + + self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl); + } +} diff --git a/tests/Platform/Bridge/Albert/GPTModelClientTest.php b/tests/Platform/Bridge/Albert/GPTModelClientTest.php new file mode 100644 index 00000000..b2c4dafe --- /dev/null +++ b/tests/Platform/Bridge/Albert/GPTModelClientTest.php @@ -0,0 +1,232 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API key must not be empty.'); + + new GPTModelClient( + new MockHttpClient(), + '', + 'https://albert.example.com/' + ); + } + + #[Test] + public function constructorThrowsExceptionForEmptyBaseUrl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The base URL must not be empty.'); + + new GPTModelClient( + new MockHttpClient(), + 'test-api-key', + '' + ); + } + + #[Test] + public function constructorWrapsHttpClientInEventSourceHttpClient(): void + { + self::expectNotToPerformAssertions(); + + $mockHttpClient = new MockHttpClient(); + + $client = new GPTModelClient( + $mockHttpClient, + 'test-api-key', + 'https://albert.example.com/' + ); + + // We can't directly test the private property, but we can verify the behavior + // by making a request and checking that it works correctly + $mockResponse = new JsonMockResponse(['choices' => []]); + $mockHttpClient->setResponseFactory([$mockResponse]); + + $model = new GPT('gpt-3.5-turbo'); + $client->request($model, ['messages' => []]); + } + + #[Test] + public function constructorAcceptsEventSourceHttpClient(): void + { + self::expectNotToPerformAssertions(); + + $mockHttpClient = new MockHttpClient(); + $eventSourceClient = new EventSourceHttpClient($mockHttpClient); + + $client = new GPTModelClient( + $eventSourceClient, + 'test-api-key', + 'https://albert.example.com/' + ); + + // Verify it works with EventSourceHttpClient + $mockResponse = new JsonMockResponse(['choices' => []]); + $mockHttpClient->setResponseFactory([$mockResponse]); + + $model = new GPT('gpt-3.5-turbo'); + $client->request($model, ['messages' => []]); + } + + #[Test] + public function supportsGPTModel(): void + { + $client = new GPTModelClient( + new MockHttpClient(), + 'test-api-key', + 'https://albert.example.com/' + ); + + $gptModel = new GPT('gpt-3.5-turbo'); + self::assertTrue($client->supports($gptModel)); + } + + #[Test] + public function doesNotSupportNonGPTModel(): void + { + $client = new GPTModelClient( + new MockHttpClient(), + 'test-api-key', + 'https://albert.example.com/' + ); + + $embeddingsModel = new Embeddings('text-embedding-ada-002'); + self::assertFalse($client->supports($embeddingsModel)); + } + + #[Test] + #[DataProvider('providePayloadToJson')] + public function requestSendsCorrectHttpRequest(array|string $payload, array $options, array|string $expectedJson): void + { + $capturedRequest = null; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedRequest) { + $capturedRequest = ['method' => $method, 'url' => $url, 'options' => $options]; + + return new JsonMockResponse(['choices' => []]); + }); + + $client = new GPTModelClient( + $httpClient, + 'test-api-key', + 'https://albert.example.com/v1' + ); + + $model = new GPT('gpt-3.5-turbo'); + $response = $client->request($model, $payload, $options); + + self::assertNotNull($capturedRequest); + self::assertSame('POST', $capturedRequest['method']); + self::assertSame('https://albert.example.com/v1/chat/completions', $capturedRequest['url']); + self::assertArrayHasKey('normalized_headers', $capturedRequest['options']); + self::assertArrayHasKey('authorization', $capturedRequest['options']['normalized_headers']); + self::assertStringContainsString('Bearer test-api-key', (string) $capturedRequest['options']['normalized_headers']['authorization'][0]); + + // Check JSON body - it might be in 'body' after processing + if (isset($capturedRequest['options']['body'])) { + $actualJson = json_decode($capturedRequest['options']['body'], true); + self::assertEquals($expectedJson, $actualJson); + } else { + self::assertSame($expectedJson, $capturedRequest['options']['json']); + } + } + + public static function providePayloadToJson(): iterable + { + yield 'with array payload and no options' => [ + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo'], + [], + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo'], + ]; + + yield 'with string payload and no options' => [ + 'test message', + [], + 'test message', + ]; + + yield 'with array payload and options' => [ + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo'], + ['temperature' => 0.7, 'max_tokens' => 150], + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo', 'temperature' => 0.7, 'max_tokens' => 150], + ]; + + yield 'options override payload values' => [ + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo', 'temperature' => 1.0], + ['temperature' => 0.5], + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo', 'temperature' => 0.5], + ]; + + yield 'with streaming option' => [ + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo'], + ['stream' => true], + ['messages' => [['role' => 'user', 'content' => 'Hello']], 'model' => 'gpt-3.5-turbo', 'stream' => true], + ]; + } + + #[Test] + public function requestHandlesBaseUrlWithoutTrailingSlash(): void + { + $capturedUrl = null; + $httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) { + $capturedUrl = $url; + + return new JsonMockResponse(['choices' => []]); + }); + + $client = new GPTModelClient( + $httpClient, + 'test-api-key', + 'https://albert.example.com/v1' + ); + + $model = new GPT('gpt-3.5-turbo'); + $client->request($model, ['messages' => []]); + + self::assertSame('https://albert.example.com/v1/chat/completions', $capturedUrl); + } + + #[Test] + public function requestHandlesBaseUrlWithTrailingSlash(): void + { + $capturedUrl = null; + $httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) { + $capturedUrl = $url; + + return new JsonMockResponse(['choices' => []]); + }); + + $client = new GPTModelClient( + $httpClient, + 'test-api-key', + 'https://albert.example.com/v1' + ); + + $model = new GPT('gpt-3.5-turbo'); + $client->request($model, ['messages' => []]); + + self::assertSame('https://albert.example.com/v1/chat/completions', $capturedUrl); + } +} diff --git a/tests/Platform/Bridge/Albert/PlatformFactoryTest.php b/tests/Platform/Bridge/Albert/PlatformFactoryTest.php new file mode 100644 index 00000000..d2d83711 --- /dev/null +++ b/tests/Platform/Bridge/Albert/PlatformFactoryTest.php @@ -0,0 +1,91 @@ + ['https://albert.example.com/v1']; + yield 'with v2 path' => ['https://albert.example.com/v2']; + yield 'with v3 path' => ['https://albert.example.com/v3']; + yield 'with v10 path' => ['https://albert.example.com/v10']; + yield 'with v99 path' => ['https://albert.example.com/v99']; + } + + #[Test] + public function throwsExceptionForNonHttpsUrl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The Albert URL must start with "https://".'); + + PlatformFactory::create('test-key', 'http://albert.example.com'); + } + + #[Test] + #[DataProvider('provideUrlsWithTrailingSlash')] + public function throwsExceptionForUrlsWithTrailingSlash(string $url): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The Albert URL must not end with a trailing slash.'); + + PlatformFactory::create('test-key', $url); + } + + public static function provideUrlsWithTrailingSlash(): \Iterator + { + yield 'with trailing slash only' => ['https://albert.example.com/']; + yield 'with v1 and trailing slash' => ['https://albert.example.com/v1/']; + yield 'with v2 and trailing slash' => ['https://albert.example.com/v2/']; + } + + #[Test] + #[DataProvider('provideUrlsWithoutVersion')] + public function throwsExceptionForUrlsWithoutVersion(string $url): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The Albert URL must include an API version (e.g., /v1, /v2).'); + + PlatformFactory::create('test-key', $url); + } + + public static function provideUrlsWithoutVersion(): \Iterator + { + yield 'without version' => ['https://albert.example.com']; + yield 'with vx path' => ['https://albert.example.com/vx']; + yield 'with version path' => ['https://albert.example.com/version']; + yield 'with api path' => ['https://albert.example.com/api']; + yield 'with v path only' => ['https://albert.example.com/v']; + yield 'with v- path' => ['https://albert.example.com/v-1']; + } +}