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

Commit 7c7ab2c

Browse files
authored
feat: add Albert API support (#366)
## Description 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. ## Key Features - 🇫🇷 **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 ## Implementation Details - 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 ## Usage Example ```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); ``` ## Testing - Added unit tests for PlatformFactory - Added example script demonstrating Albert API usage with RAG context - All tests pass and code follows project standards ## Documentation - 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
1 parent bf72975 commit 7c7ab2c

File tree

9 files changed

+682
-0
lines changed

9 files changed

+682
-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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 (!isset($_SERVER['ALBERT_API_KEY'], $_SERVER['ALBERT_API_URL'])) {
14+
echo 'Please set the ALBERT_API_KEY and ALBERT_API_URL environment variable (e.g., https://your-albert-instance.com/v1).'.\PHP_EOL;
15+
exit(1);
16+
}
17+
18+
$platform = PlatformFactory::create($_SERVER['ALBERT_API_KEY'], $_SERVER['ALBERT_API_URL']);
19+
20+
$model = new GPT('gpt-4o');
21+
$chain = new Chain($platform, $model);
22+
23+
$documentContext = <<<'CONTEXT'
24+
Document: AI Strategy of France
25+
26+
France has launched a comprehensive national AI strategy with the following key objectives:
27+
1. Strengthening the AI ecosystem and attracting talent
28+
2. Developing sovereign AI capabilities
29+
3. Ensuring ethical and responsible AI development
30+
4. Supporting AI adoption in public services
31+
5. Investing €1.5 billion in AI research and development
32+
33+
The Albert project is part of this strategy, providing a sovereign AI solution for French public administration.
34+
CONTEXT;
35+
36+
$messages = new MessageBag(
37+
Message::forSystem(
38+
'You are an AI assistant with access to documents about French AI initiatives. '.
39+
'Use the provided context to answer questions accurately.'
40+
),
41+
Message::ofUser($documentContext),
42+
Message::ofUser('What are the main objectives of France\'s AI strategy?'),
43+
);
44+
45+
$response = $chain->call($messages);
46+
47+
echo $response->getContent().\PHP_EOL;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
/**
15+
* @author Oskar Stark <[email protected]>
16+
*/
17+
final readonly class EmbeddingsModelClient implements ModelClientInterface
18+
{
19+
public function __construct(
20+
private HttpClientInterface $httpClient,
21+
#[\SensitiveParameter] private string $apiKey,
22+
private string $baseUrl,
23+
) {
24+
'' !== $apiKey || throw new InvalidArgumentException('The API key must not be empty.');
25+
'' !== $baseUrl || throw new InvalidArgumentException('The base URL must not be empty.');
26+
}
27+
28+
public function supports(Model $model): bool
29+
{
30+
return $model instanceof Embeddings;
31+
}
32+
33+
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
34+
{
35+
return $this->httpClient->request('POST', \sprintf('%s/embeddings', $this->baseUrl), [
36+
'auth_bearer' => $this->apiKey,
37+
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
38+
]);
39+
}
40+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
/**
16+
* @author Oskar Stark <[email protected]>
17+
*/
18+
final readonly class GPTModelClient implements ModelClientInterface
19+
{
20+
private EventSourceHttpClient $httpClient;
21+
22+
public function __construct(
23+
HttpClientInterface $httpClient,
24+
#[\SensitiveParameter] private string $apiKey,
25+
private string $baseUrl,
26+
) {
27+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
28+
29+
'' !== $apiKey || throw new InvalidArgumentException('The API key must not be empty.');
30+
'' !== $baseUrl || throw new InvalidArgumentException('The base URL must not be empty.');
31+
}
32+
33+
public function supports(Model $model): bool
34+
{
35+
return $model instanceof GPT;
36+
}
37+
38+
public function request(Model $model, array|string $payload, array $options = []): ResponseInterface
39+
{
40+
return $this->httpClient->request('POST', \sprintf('%s/chat/completions', $this->baseUrl), [
41+
'auth_bearer' => $this->apiKey,
42+
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
43+
]);
44+
}
45+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
/**
16+
* @author Oskar Stark <[email protected]>
17+
*/
18+
final class PlatformFactory
19+
{
20+
public static function create(
21+
#[\SensitiveParameter] string $apiKey,
22+
string $baseUrl,
23+
?HttpClientInterface $httpClient = null,
24+
): Platform {
25+
str_starts_with($baseUrl, 'https://') || throw new InvalidArgumentException('The Albert URL must start with "https://".');
26+
!str_ends_with($baseUrl, '/') || throw new InvalidArgumentException('The Albert URL must not end with a trailing slash.');
27+
preg_match('/\/v\d+$/', $baseUrl) || throw new InvalidArgumentException('The Albert URL must include an API version (e.g., /v1, /v2).');
28+
29+
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
30+
31+
return new Platform(
32+
[
33+
new GPTModelClient($httpClient, $apiKey, $baseUrl),
34+
new EmbeddingsModelClient($httpClient, $apiKey, $baseUrl),
35+
],
36+
[new GPTResponseConverter(), new EmbeddingsResponseConverter()],
37+
Contract::create(),
38+
);
39+
}
40+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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\DataProvider;
13+
use PHPUnit\Framework\Attributes\Small;
14+
use PHPUnit\Framework\Attributes\Test;
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+
#[DataProvider('providePayloadToJson')]
77+
public function requestSendsCorrectHttpRequest(array|string $payload, array $options, array|string $expectedJson): void
78+
{
79+
$capturedRequest = null;
80+
$httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedRequest) {
81+
$capturedRequest = ['method' => $method, 'url' => $url, 'options' => $options];
82+
83+
return new JsonMockResponse(['data' => []]);
84+
});
85+
86+
$client = new EmbeddingsModelClient(
87+
$httpClient,
88+
'test-api-key',
89+
'https://albert.example.com/v1'
90+
);
91+
92+
$model = new Embeddings('text-embedding-ada-002');
93+
$response = $client->request($model, $payload, $options);
94+
95+
self::assertNotNull($capturedRequest);
96+
self::assertSame('POST', $capturedRequest['method']);
97+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedRequest['url']);
98+
self::assertArrayHasKey('normalized_headers', $capturedRequest['options']);
99+
self::assertArrayHasKey('authorization', $capturedRequest['options']['normalized_headers']);
100+
self::assertStringContainsString('Bearer test-api-key', (string) $capturedRequest['options']['normalized_headers']['authorization'][0]);
101+
102+
// Check JSON body - it might be in 'body' after processing
103+
if (isset($capturedRequest['options']['body'])) {
104+
$actualJson = json_decode($capturedRequest['options']['body'], true);
105+
self::assertEquals($expectedJson, $actualJson);
106+
} else {
107+
self::assertSame($expectedJson, $capturedRequest['options']['json']);
108+
}
109+
}
110+
111+
public static function providePayloadToJson(): iterable
112+
{
113+
yield 'with array payload and no options' => [
114+
['input' => 'test text', 'model' => 'text-embedding-ada-002'],
115+
[],
116+
['input' => 'test text', 'model' => 'text-embedding-ada-002'],
117+
];
118+
119+
yield 'with string payload and no options' => [
120+
'test text',
121+
[],
122+
'test text',
123+
];
124+
125+
yield 'with array payload and options' => [
126+
['input' => 'test text', 'model' => 'text-embedding-ada-002'],
127+
['dimensions' => 1536],
128+
['dimensions' => 1536, 'input' => 'test text', 'model' => 'text-embedding-ada-002'],
129+
];
130+
131+
yield 'options override payload values' => [
132+
['input' => 'test text', 'model' => 'text-embedding-ada-002'],
133+
['model' => 'text-embedding-3-small'],
134+
['model' => 'text-embedding-3-small', 'input' => 'test text'],
135+
];
136+
}
137+
138+
#[Test]
139+
public function requestHandlesBaseUrlWithoutTrailingSlash(): void
140+
{
141+
$capturedUrl = null;
142+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
143+
$capturedUrl = $url;
144+
145+
return new JsonMockResponse(['data' => []]);
146+
});
147+
148+
$client = new EmbeddingsModelClient(
149+
$httpClient,
150+
'test-api-key',
151+
'https://albert.example.com/v1'
152+
);
153+
154+
$model = new Embeddings('text-embedding-ada-002');
155+
$client->request($model, ['input' => 'test']);
156+
157+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
158+
}
159+
160+
#[Test]
161+
public function requestHandlesBaseUrlWithTrailingSlash(): void
162+
{
163+
$capturedUrl = null;
164+
$httpClient = new MockHttpClient(function ($method, $url) use (&$capturedUrl) {
165+
$capturedUrl = $url;
166+
167+
return new JsonMockResponse(['data' => []]);
168+
});
169+
170+
$client = new EmbeddingsModelClient(
171+
$httpClient,
172+
'test-api-key',
173+
'https://albert.example.com/v1'
174+
);
175+
176+
$model = new Embeddings('text-embedding-ada-002');
177+
$client->request($model, ['input' => 'test']);
178+
179+
self::assertSame('https://albert.example.com/v1/embeddings', $capturedUrl);
180+
}
181+
}

0 commit comments

Comments
 (0)