This repository was archived by the owner on Jul 16, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 24
feat: add Albert API support #366
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
]); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
]); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
181
tests/Platform/Bridge/Albert/EmbeddingsModelClientTest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.