Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit ad8a9b4

Browse files
committed
feat: Add Albert API platform support with comprehensive tests
- Add Albert-specific model clients for OpenAI-compatible API - Add PlatformFactory for easy Albert platform creation - Add comprehensive unit tests for all components - Require explicit API version in URL (e.g., /v1, /v2) - Add strict URL validation (HTTPS required, no trailing slashes) - Use plain PHP exception handling instead of external assertions - Apply consistent code style and rector fixes
1 parent bf72975 commit ad8a9b4

File tree

9 files changed

+635
-0
lines changed

9 files changed

+635
-0
lines changed

.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,9 @@ RUN_EXPENSIVE_EXAMPLES=false
6565
# For using Gemini
6666
GEMINI_API_KEY=
6767

68+
# For using Albert API (French Sovereign AI)
69+
ALBERT_API_KEY=
70+
ALBERT_API_URL=
71+
6872
# For MariaDB store. Server defined in compose.yaml
6973
MARIADB_URI=pdo-mysql://[email protected]:3309/my_database

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ $embeddings = new Embeddings();
6969
* [DeepSeek's R1](https://www.deepseek.com/) with [OpenRouter](https://www.openrouter.com/) as Platform
7070
* [Amazon's Nova](https://nova.amazon.com) with [AWS](https://aws.amazon.com/bedrock/) as Platform
7171
* [Mistral's Mistral](https://www.mistral.ai/) with [Mistral](https://www.mistral.ai/) as Platform
72+
* [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)
7273
* Embeddings Models
7374
* [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
7475
* [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, [
166167
1. [Google's Gemini with Google](examples/google/chat.php)
167168
1. [Google's Gemini with OpenRouter](examples/openrouter/chat-gemini.php)
168169
1. [Mistral's Mistral with Mistral](examples/mistral/chat-mistral.php)
170+
1. [Albert API (French Sovereign AI)](examples/albert/chat.php)
169171

170172
### Tools
171173

examples/albert/chat.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpLlm\LlmChain\Chain\Chain;
6+
use PhpLlm\LlmChain\Platform\Bridge\Albert\PlatformFactory;
7+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
8+
use PhpLlm\LlmChain\Platform\Message\Message;
9+
use PhpLlm\LlmChain\Platform\Message\MessageBag;
10+
11+
require_once dirname(__DIR__).'/../vendor/autoload.php';
12+
13+
if (empty($_ENV['ALBERT_API_KEY'])) {
14+
echo 'Please set the ALBERT_API_KEY environment variable.'.\PHP_EOL;
15+
exit(1);
16+
}
17+
18+
if (empty($_ENV['ALBERT_API_URL'])) {
19+
echo 'Please set the ALBERT_API_URL environment variable (e.g., https://your-albert-instance.com/v1).'.\PHP_EOL;
20+
exit(1);
21+
}
22+
23+
$platform = PlatformFactory::create(
24+
apiKey: $_ENV['ALBERT_API_KEY'],
25+
baseUrl: $_ENV['ALBERT_API_URL'],
26+
);
27+
28+
$model = new GPT('gpt-4o');
29+
$chain = new Chain($platform, $model);
30+
31+
$documentContext = <<<'CONTEXT'
32+
Document: AI Strategy of France
33+
34+
France has launched a comprehensive national AI strategy with the following key objectives:
35+
1. Strengthening the AI ecosystem and attracting talent
36+
2. Developing sovereign AI capabilities
37+
3. Ensuring ethical and responsible AI development
38+
4. Supporting AI adoption in public services
39+
5. Investing €1.5 billion in AI research and development
40+
41+
The Albert project is part of this strategy, providing a sovereign AI solution for French public administration.
42+
CONTEXT;
43+
44+
$messages = new MessageBag(
45+
Message::forSystem(
46+
'You are an AI assistant with access to documents about French AI initiatives. '.
47+
'Use the provided context to answer questions accurately.'
48+
),
49+
Message::ofUser($documentContext),
50+
Message::ofUser('What are the main objectives of France\'s AI strategy?'),
51+
);
52+
53+
$response = $chain->call($messages);
54+
55+
echo $response->getContent().\PHP_EOL;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
8+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\ModelClientInterface;
11+
use Symfony\Contracts\HttpClient\HttpClientInterface;
12+
use Symfony\Contracts\HttpClient\ResponseInterface;
13+
14+
final readonly class EmbeddingsModelClient implements ModelClientInterface
15+
{
16+
public function __construct(
17+
private HttpClientInterface $httpClient,
18+
#[\SensitiveParameter] private string $apiKey,
19+
private string $baseUrl,
20+
) {
21+
if ('' === $apiKey) {
22+
throw new InvalidArgumentException('The API key must not be empty.');
23+
}
24+
if ('' === $baseUrl) {
25+
throw new InvalidArgumentException('The base URL must not be empty.');
26+
}
27+
}
28+
29+
public function supports(Model $model): bool
30+
{
31+
return $model instanceof Embeddings;
32+
}
33+
34+
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
35+
{
36+
return $this->httpClient->request('POST', \sprintf('%s/embeddings', $this->baseUrl), [
37+
'auth_bearer' => $this->apiKey,
38+
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
39+
]);
40+
}
41+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
8+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\ModelClientInterface;
11+
use Symfony\Component\HttpClient\EventSourceHttpClient;
12+
use Symfony\Contracts\HttpClient\HttpClientInterface;
13+
use Symfony\Contracts\HttpClient\ResponseInterface;
14+
15+
final readonly class GPTModelClient implements ModelClientInterface
16+
{
17+
private EventSourceHttpClient $httpClient;
18+
19+
public function __construct(
20+
HttpClientInterface $httpClient,
21+
#[\SensitiveParameter] private string $apiKey,
22+
private string $baseUrl,
23+
) {
24+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
25+
26+
if ('' === $apiKey) {
27+
throw new InvalidArgumentException('The API key must not be empty.');
28+
}
29+
if ('' === $baseUrl) {
30+
throw new InvalidArgumentException('The base URL must not be empty.');
31+
}
32+
}
33+
34+
public function supports(Model $model): bool
35+
{
36+
return $model instanceof GPT;
37+
}
38+
39+
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
40+
{
41+
return $this->httpClient->request('POST', \sprintf('%s/chat/completions', $this->baseUrl), [
42+
'auth_bearer' => $this->apiKey,
43+
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
44+
]);
45+
}
46+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Albert;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
8+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter;
9+
use PhpLlm\LlmChain\Platform\Contract;
10+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
11+
use PhpLlm\LlmChain\Platform\Platform;
12+
use Symfony\Component\HttpClient\EventSourceHttpClient;
13+
use Symfony\Contracts\HttpClient\HttpClientInterface;
14+
15+
final class PlatformFactory
16+
{
17+
public static function create(
18+
#[\SensititiveParameter] string $apiKey,
19+
string $baseUrl,
20+
?HttpClientInterface $httpClient = null,
21+
): Platform {
22+
if (!str_starts_with($baseUrl, 'https://')) {
23+
throw new InvalidArgumentException('The Albert URL must start with "https://".');
24+
}
25+
26+
if (str_ends_with($baseUrl, '/')) {
27+
throw new InvalidArgumentException('The Albert URL must not end with a trailing slash.');
28+
}
29+
30+
if (!preg_match('/\/v\d+$/', $baseUrl)) {
31+
throw new InvalidArgumentException('The Albert URL must include an API version (e.g., /v1, /v2).');
32+
}
33+
34+
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
35+
36+
$gptClient = new GPTModelClient($httpClient, $apiKey, $baseUrl);
37+
$embeddingsClient = new EmbeddingsModelClient($httpClient, $apiKey, $baseUrl);
38+
39+
return new Platform(
40+
[
41+
$gptClient,
42+
$embeddingsClient,
43+
],
44+
[
45+
new GPTResponseConverter(),
46+
new EmbeddingsResponseConverter(),
47+
],
48+
Contract::create(),
49+
);
50+
}
51+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Tests\Platform\Bridge\Albert;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Albert\EmbeddingsModelClient;
8+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
9+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
10+
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\Attributes\Small;
13+
use PHPUnit\Framework\Attributes\Test;
14+
use PHPUnit\Framework\Attributes\TestWith;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\HttpClient\MockHttpClient;
17+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
18+
19+
#[CoversClass(EmbeddingsModelClient::class)]
20+
#[Small]
21+
final class EmbeddingsModelClientTest extends TestCase
22+
{
23+
#[Test]
24+
public function constructorThrowsExceptionForEmptyApiKey(): void
25+
{
26+
$this->expectException(InvalidArgumentException::class);
27+
$this->expectExceptionMessage('The API key must not be empty.');
28+
29+
new EmbeddingsModelClient(
30+
new MockHttpClient(),
31+
'',
32+
'https://albert.example.com/'
33+
);
34+
}
35+
36+
#[Test]
37+
public function constructorThrowsExceptionForEmptyBaseUrl(): void
38+
{
39+
$this->expectException(InvalidArgumentException::class);
40+
$this->expectExceptionMessage('The base URL must not be empty.');
41+
42+
new EmbeddingsModelClient(
43+
new MockHttpClient(),
44+
'test-api-key',
45+
''
46+
);
47+
}
48+
49+
#[Test]
50+
public function supportsEmbeddingsModel(): void
51+
{
52+
$client = new EmbeddingsModelClient(
53+
new MockHttpClient(),
54+
'test-api-key',
55+
'https://albert.example.com/'
56+
);
57+
58+
$embeddingsModel = new Embeddings('text-embedding-ada-002');
59+
self::assertTrue($client->supports($embeddingsModel));
60+
}
61+
62+
#[Test]
63+
public function doesNotSupportNonEmbeddingsModel(): void
64+
{
65+
$client = new EmbeddingsModelClient(
66+
new MockHttpClient(),
67+
'test-api-key',
68+
'https://albert.example.com/'
69+
);
70+
71+
$gptModel = new GPT('gpt-3.5-turbo');
72+
self::assertFalse($client->supports($gptModel));
73+
}
74+
75+
#[Test]
76+
#[TestWith([['input' => 'test text', 'model' => 'text-embedding-ada-002'], [], ['input' => 'test text', 'model' => 'text-embedding-ada-002']], name: 'with array payload and no options')]
77+
#[TestWith(['test text', [], 'test text'], name: 'with string payload and no options')]
78+
#[TestWith([['input' => 'test text', 'model' => 'text-embedding-ada-002'], ['dimensions' => 1536], ['dimensions' => 1536, 'input' => 'test text', 'model' => 'text-embedding-ada-002']], name: 'with array payload and options')]
79+
#[TestWith([['input' => 'test text', 'model' => 'text-embedding-ada-002'], ['model' => 'text-embedding-3-small'], ['model' => 'text-embedding-3-small', 'input' => 'test text']], name: 'options override payload values')]
80+
public function requestSendsCorrectHttpRequest(array|string $payload, array $options, array|string $expectedJson): void
81+
{
82+
$capturedRequest = null;
83+
$httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedRequest) {
84+
$capturedRequest = ['method' => $method, 'url' => $url, 'options' => $options];
85+
86+
return new JsonMockResponse(['data' => []]);
87+
});
88+
89+
$client = new EmbeddingsModelClient(
90+
$httpClient,
91+
'test-api-key',
92+
'https://albert.example.com/v1'
93+
);
94+
95+
$model = new Embeddings('text-embedding-ada-002');
96+
$response = $client->request($model, $payload, $options);
97+
98+
self::assertNotNull($capturedRequest);
99+
self::assertSame('POST', $capturedRequest['method']);
100+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedRequest['url']);
101+
self::assertArrayHasKey('normalized_headers', $capturedRequest['options']);
102+
self::assertArrayHasKey('authorization', $capturedRequest['options']['normalized_headers']);
103+
self::assertStringContainsString('Bearer test-api-key', $capturedRequest['options']['normalized_headers']['authorization'][0]);
104+
105+
// Check JSON body - it might be in 'body' after processing
106+
if (isset($capturedRequest['options']['body'])) {
107+
$actualJson = json_decode($capturedRequest['options']['body'], true);
108+
self::assertEquals($expectedJson, $actualJson);
109+
} else {
110+
self::assertSame($expectedJson, $capturedRequest['options']['json']);
111+
}
112+
}
113+
114+
#[Test]
115+
public function requestHandlesBaseUrlWithoutTrailingSlash(): void
116+
{
117+
$capturedUrl = null;
118+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
119+
$capturedUrl = $url;
120+
121+
return new JsonMockResponse(['data' => []]);
122+
});
123+
124+
$client = new EmbeddingsModelClient(
125+
$httpClient,
126+
'test-api-key',
127+
'https://albert.example.com/v1'
128+
);
129+
130+
$model = new Embeddings('text-embedding-ada-002');
131+
$client->request($model, ['input' => 'test']);
132+
133+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
134+
}
135+
136+
#[Test]
137+
public function requestHandlesBaseUrlWithTrailingSlash(): void
138+
{
139+
$capturedUrl = null;
140+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
141+
$capturedUrl = $url;
142+
143+
return new JsonMockResponse(['data' => []]);
144+
});
145+
146+
$client = new EmbeddingsModelClient(
147+
$httpClient,
148+
'test-api-key',
149+
'https://albert.example.com/v1'
150+
);
151+
152+
$model = new Embeddings('text-embedding-ada-002');
153+
$client->request($model, ['input' => 'test']);
154+
155+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
156+
}
157+
}

0 commit comments

Comments
 (0)