From 99ce42a72c2a14d28fe128cf92560c278c064236 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 16 Jul 2025 23:17:26 +0200 Subject: [PATCH] feat: add Albert API support (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for [Albert API](https://github.com/etalab-ia/albert-api), the French government's sovereign AI gateway. Albert provides an OpenAI-compatible API interface for various language models while ensuring data sovereignty and compliance with French/EU regulations. - 🇫🇷 **Sovereign AI**: Albert is hosted and operated by the French government - 🔧 **OpenAI-compatible**: Uses the same API structure as OpenAI - 🔒 **Data Privacy**: Ensures data remains within French/EU jurisdiction - 🎯 **Public Sector Focus**: Designed for use by French public administration - Created Albert-specific model clients (GPTModelClient and EmbeddingsModelClient) with configurable base URL - Added PlatformFactory for easy Albert API initialization - Reuses OpenAI's response converters for compatibility - Supports both chat completions and embeddings endpoints ```php use PhpLlm\LlmChain\Platform\Bridge\Albert\PlatformFactory; use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; $platform = PlatformFactory::create( apiKey: $_ENV['ALBERT_API_KEY'], albertUrl: $_ENV['ALBERT_API_URL'] ); $model = new GPT('gpt-4o'); $chain = new Chain($platform, $model); ``` - Added unit tests for PlatformFactory - Added example script demonstrating Albert API usage with RAG context - All tests pass and code follows project standards - Updated README to list Albert as a supported platform - Added example file showing how to use Albert API - Included context about French AI strategy in the example This implementation allows French public sector organizations to leverage LLM Chain while maintaining data sovereignty and regulatory compliance. Closes #346 --- examples/.env | 4 + examples/albert/chat.php | 54 ++++ src/platform/doc/index.rst | 3 + src/platform/phpstan.dist.neon | 2 +- .../Bridge/Albert/EmbeddingsModelClient.php | 47 ++++ .../src/Bridge/Albert/GPTModelClient.php | 52 ++++ .../src/Bridge/Albert/PlatformFactory.php | 47 ++++ .../Albert/EmbeddingsModelClientTest.php | 188 ++++++++++++++ .../Bridge/Albert/GPTModelClientTest.php | 239 ++++++++++++++++++ .../Bridge/Albert/PlatformFactoryTest.php | 98 +++++++ 10 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 examples/albert/chat.php create mode 100644 src/platform/src/Bridge/Albert/EmbeddingsModelClient.php create mode 100644 src/platform/src/Bridge/Albert/GPTModelClient.php create mode 100644 src/platform/src/Bridge/Albert/PlatformFactory.php create mode 100644 src/platform/tests/Bridge/Albert/EmbeddingsModelClientTest.php create mode 100644 src/platform/tests/Bridge/Albert/GPTModelClientTest.php create mode 100644 src/platform/tests/Bridge/Albert/PlatformFactoryTest.php diff --git a/examples/.env b/examples/.env index 62906847..58407fe4 100644 --- a/examples/.env +++ b/examples/.env @@ -68,6 +68,10 @@ 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/examples/albert/chat.php b/examples/albert/chat.php new file mode 100644 index 00000000..809f181e --- /dev/null +++ b/examples/albert/chat.php @@ -0,0 +1,54 @@ + + * + * 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\Platform\Bridge\Albert\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/../vendor/autoload.php'; + +if (!isset($_SERVER['ALBERT_API_KEY'], $_SERVER['ALBERT_API_URL'])) { + echo 'Please set the ALBERT_API_KEY and ALBERT_API_URL environment variable (e.g., https://your-albert-instance.com/v1).'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_SERVER['ALBERT_API_KEY'], $_SERVER['ALBERT_API_URL']); + +$model = new GPT('gpt-4o'); +$agent = new Agent($platform, $model); + +$documentContext = <<<'CONTEXT' + Document: AI Strategy of France + + France has launched a comprehensive national AI strategy with the following key objectives: + 1. Strengthening the AI ecosystem and attracting talent + 2. Developing sovereign AI capabilities + 3. Ensuring ethical and responsible AI development + 4. Supporting AI adoption in public services + 5. Investing €1.5 billion in AI research and development + + The Albert project is part of this strategy, providing a sovereign AI solution for French public administration. + CONTEXT; + +$messages = new MessageBag( + Message::forSystem( + 'You are an AI assistant with access to documents about French AI initiatives. '. + 'Use the provided context to answer questions accurately.' + ), + Message::ofUser($documentContext), + Message::ofUser('What are the main objectives of France\'s AI strategy?'), +); + +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/src/platform/doc/index.rst b/src/platform/doc/index.rst index d13ea15f..5d912d0a 100644 --- a/src/platform/doc/index.rst +++ b/src/platform/doc/index.rst @@ -77,6 +77,7 @@ usually defined by the specific models and their documentation. * `DeepSeek's R1`_ with `OpenRouter`_ as Platform * `Amazon's Nova`_ with `AWS Bedrock`_ as Platform * `Mistral's Mistral`_ with `Mistral`_ as Platform + * `Albert API`_ models with `Albert`_ as Platform (French government's sovereign AI gateway) * **Embeddings Models** * `Google's Text Embeddings`_ with `Google`_ * `OpenAI's Text Embeddings`_ with `OpenAI`_ and `Azure`_ as Platform @@ -307,6 +308,8 @@ which can be useful to speed up the processing:: .. _`DeepSeek's R1`: https://www.deepseek.com/ .. _`Amazon's Nova`: https://nova.amazon.com .. _`Mistral's Mistral`: https://www.mistral.ai/ +.. _`Albert API`: https://github.com/etalab-ia/albert-api +.. _`Albert`: https://alliance.numerique.gouv.fr/produit/albert/ .. _`Mistral`: https://www.mistral.ai/ .. _`Google's Text Embeddings`: https://ai.google.dev/gemini-api/docs/embeddings .. _`OpenAI's Text Embeddings`: https://platform.openai.com/docs/guides/embeddings/embedding-models diff --git a/src/platform/phpstan.dist.neon b/src/platform/phpstan.dist.neon index d2bb0f50..1762f050 100644 --- a/src/platform/phpstan.dist.neon +++ b/src/platform/phpstan.dist.neon @@ -17,5 +17,5 @@ parameters: path: src/* reportUnmatched: false # only needed for older Symfony versions - - message: '#no value type specified in iterable type array#' + identifier: missingType.iterableValue path: tests/* diff --git a/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php b/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php new file mode 100644 index 00000000..580ef0b1 --- /dev/null +++ b/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Albert; + +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Oskar Stark + */ +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/src/Bridge/Albert/GPTModelClient.php b/src/platform/src/Bridge/Albert/GPTModelClient.php new file mode 100644 index 00000000..238d0213 --- /dev/null +++ b/src/platform/src/Bridge/Albert/GPTModelClient.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Albert; + +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Oskar Stark + */ +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/src/Bridge/Albert/PlatformFactory.php b/src/platform/src/Bridge/Albert/PlatformFactory.php new file mode 100644 index 00000000..b43062e0 --- /dev/null +++ b/src/platform/src/Bridge/Albert/PlatformFactory.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Albert; + +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter; +use Symfony\AI\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Oskar Stark + */ +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/src/platform/tests/Bridge/Albert/EmbeddingsModelClientTest.php b/src/platform/tests/Bridge/Albert/EmbeddingsModelClientTest.php new file mode 100644 index 00000000..72207607 --- /dev/null +++ b/src/platform/tests/Bridge/Albert/EmbeddingsModelClientTest.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Albert; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Albert\EmbeddingsModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(EmbeddingsModelClient::class)] +#[Small] +final class EmbeddingsModelClientTest extends TestCase +{ + #[Test] + public function constructorThrowsExceptionForEmptyApiKey(): void + { + $this->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/src/platform/tests/Bridge/Albert/GPTModelClientTest.php b/src/platform/tests/Bridge/Albert/GPTModelClientTest.php new file mode 100644 index 00000000..a19bed03 --- /dev/null +++ b/src/platform/tests/Bridge/Albert/GPTModelClientTest.php @@ -0,0 +1,239 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Albert; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Albert\GPTModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(GPTModelClient::class)] +#[Small] +final class GPTModelClientTest extends TestCase +{ + #[Test] + public function constructorThrowsExceptionForEmptyApiKey(): void + { + $this->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/src/platform/tests/Bridge/Albert/PlatformFactoryTest.php b/src/platform/tests/Bridge/Albert/PlatformFactoryTest.php new file mode 100644 index 00000000..40a91da6 --- /dev/null +++ b/src/platform/tests/Bridge/Albert/PlatformFactoryTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Albert; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Albert\PlatformFactory; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Platform; + +#[CoversClass(PlatformFactory::class)] +#[Small] +final class PlatformFactoryTest extends TestCase +{ + #[Test] + public function createsPlatformWithCorrectBaseUrl(): void + { + $platform = PlatformFactory::create('test-key', 'https://albert.example.com/v1'); + + self::assertInstanceOf(Platform::class, $platform); + } + + #[Test] + #[DataProvider('provideValidUrls')] + public function handlesUrlsCorrectly(string $url): void + { + $platform = PlatformFactory::create('test-key', $url); + + self::assertInstanceOf(Platform::class, $platform); + } + + public static function provideValidUrls(): \Iterator + { + yield 'with v1 path' => ['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']; + } +}