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

feat: add Albert API support #366

Merged
merged 1 commit into from
Jul 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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://[email protected]:3309/my_database
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions examples/albert/chat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

use PhpLlm\LlmChain\Chain\Chain;
use PhpLlm\LlmChain\Platform\Bridge\Albert\PlatformFactory;
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
use PhpLlm\LlmChain\Platform\Message\Message;
use PhpLlm\LlmChain\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');
$chain = new Chain($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 = $chain->call($messages);

echo $response->getContent().\PHP_EOL;
40 changes: 40 additions & 0 deletions src/Platform/Bridge/Albert/EmbeddingsModelClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Platform\Bridge\Albert;

use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
use PhpLlm\LlmChain\Platform\Model;
use PhpLlm\LlmChain\Platform\ModelClientInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

/**
* @author Oskar Stark <[email protected]>
*/
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,
]);
}
}
45 changes: 45 additions & 0 deletions src/Platform/Bridge/Albert/GPTModelClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Platform\Bridge\Albert;

use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
use PhpLlm\LlmChain\Platform\Model;
use PhpLlm\LlmChain\Platform\ModelClientInterface;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

/**
* @author Oskar Stark <[email protected]>
*/
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,
]);
}
}
40 changes: 40 additions & 0 deletions src/Platform/Bridge/Albert/PlatformFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Platform\Bridge\Albert;

use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter;
use PhpLlm\LlmChain\Platform\Contract;
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
use PhpLlm\LlmChain\Platform\Platform;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @author Oskar Stark <[email protected]>
*/
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(),
);
}
}
181 changes: 181 additions & 0 deletions tests/Platform/Bridge/Albert/EmbeddingsModelClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Tests\Platform\Bridge\Albert;

use PhpLlm\LlmChain\Platform\Bridge\Albert\EmbeddingsModelClient;
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings;
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException;
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\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);
}
}
Loading