symfony/ai-albert-platform
Symfony AI platform bridge for Albert, the French government's OpenAI-compatible LLM API (OpenGateLLM). Wire Albert models into your Symfony apps for chat and completions alongside other Symfony AI platforms.
The README is a stub — it links to Albert's ReDoc reference and the OpenGateLLM source, but contains no installation snippet or usage example. Budget an hour to read src/ before you write anything. The two files that matter are the PlatformFactory (how the platform is constructed) and the model/capability classes (which models declare CHAT, EMBEDDINGS, tool-calling).
1. Confirm the endpoint with curl before touching PHP. You want to know the real base URL, whether it needs /v1, which model IDs are actually served, and whether your key works:
curl -s https://albert.api.etalab.gouv.fr/v1/models \
-H "Authorization: Bearer $ALBERT_API_KEY" | jq '.data[].id'
Keep that list — you will hardcode those IDs, because the bridge cannot discover Albert's catalog for you.
2. Install the bridge plus the platform contracts and an HTTP client:
composer require symfony/ai-platform symfony/ai-albert-platform symfony/http-client
Symfony AI bridges are versioned in lockstep with the core symfony/ai packages and sit pre-1.0; pin exact versions ("0.14.*") rather than using ^0.14. The version history is fast-moving — v0.8.0 introduced a provider abstraction, and the package has since advanced to v0.14.0 with multiple releases in a short window. Pin, read the diff between tags, and don't assume APIs from an older blog post still exist.
3. Wire it into the container. There is no Laravel service provider to publish, no facade, no config file. You write the binding yourself:
// app/Providers/AiServiceProvider.php
public function register(): void
{
$this->app->singleton(PlatformInterface::class, fn () => PlatformFactory::create(
apiKey: config('services.albert.key'),
baseUrl: 'https://albert.api.etalab.gouv.fr/v1',
));
}
Check src/PlatformFactory.php for the exact signature at the tag you installed — construction may go through a provider object rather than a bare create() static, and this has changed across 0.x.
4. First use case: one chat call in tinker. Do not start with streaming or embeddings. Prove the round trip:
$result = app(PlatformInterface::class)->invoke(
config('services.albert.model'), // e.g. one of the IDs from step 1
new MessageBag(
new Message('Answer in one sentence.', Role::System),
new Message('What is the Albert platform?', Role::User),
),
);
dump($result->asText());
If that prints, the transport, auth, base URL and model ID are all correct. Only then move to embeddings or streaming.
As of v0.14.0 the result object carries Albert's carbon footprint as metadata (see the #2517 changelog entry). Once the round trip works, inspect the metadata accessor on the resolved result — it's the cheapest way to confirm what fields your response actually exposes before you build anything on top of it.
Chat behind a domain service, not a controller. Wrap PlatformInterface in your own Assistant / Summarizer class so controllers and jobs never see Symfony AI types. This is the single highest-value habit with this package: when you swap providers or upgrade across a breaking pre-1.0 release, exactly one class changes.
final class Summarizer
{
public function __construct(private PlatformInterface $platform) {}
public function summarize(string $text): string
{
return $this->platform->invoke(
config('services.albert.model'),
new MessageBag(
new Message('Summarize in 3 bullet points.', Role::System),
new Message($text, Role::User),
),
['temperature' => 0.2],
)->asText();
}
}
Embeddings pipeline, with caching and batching. Embeddings are the most durable reason to use Albert (EU-hosted vectors for GDPR-sensitive corpora). Pattern: chunk → hash → cache → embed in a queued job → persist to pgvector/Meilisearch.
$hash = hash('xxh128', $model.'|'.$chunk);
$vector = Cache::remember("emb:$hash", now()->addMonth(), fn () =>
$this->platform->invoke($model, $chunk)->asVectors()
);
Cache key must include the model name — vectors from different models are not comparable, and a silent model swap will corrupt your index.
Surface carbon footprint alongside usage. v0.14.0 exposes per-call carbon footprint as result metadata. If you have any ESG / responsible-AI reporting requirement (and if you're on Albert for sovereignty reasons, you probably do), capture it at the same point you'd capture tokens and latency: inside your observability decorator, after resolving the result.
$result = $this->platform->invoke($model, $bag, $options);
$text = $result->asText();
UsageLog::create([
'model' => $model,
'tokens' => $result->getMetadata()->get('usage') ?? null,
'carbon' => $result->getMetadata()->get('carbon_footprint') ?? null, // exact key: check src/
'user_id' => $userId,
]);
Check the metadata key name against the tag you installed — this is a fresh feature and the field name is not documented outside the source. Treat a missing value as "not reported for this model/call", not as zero.
Provider fallback via a decorator. The v0.8.0 provider abstraction is where routing lives, but the simplest Laravel-native approach is a decorator bound in place of the raw platform:
final class FallbackPlatform implements PlatformInterface
{
public function __construct(
private PlatformInterface $primary,
private PlatformInterface $fallback,
) {}
public function invoke(string $model, array|string|object $input, array $options = []): DeferredResult
{
return new class($this->primary, $this->fallback, $model, $input, $options) extends DeferredResult {
// resolve primary; on failure resolve fallback
};
}
}
Note the awkwardness there — see the deferred-execution gotcha below. If you need real failover, resolve eagerly inside the decorator (->asText() in a try/catch) rather than returning a lazy result.
Queue the slow paths. Chat latency from outside the EU is unpredictable, and Albert is a public-sector service with quotas. Put any non-interactive generation in a job with RateLimited middleware or Redis::throttle, and give the job a retryUntil plus exponential backoff for 429s.
Testing: fake the platform, not the HTTP layer. Because the bridge uses Symfony HttpClient, Laravel's Http::fake() will not intercept anything. Either bind a stub PlatformInterface in the container, or inject MockHttpClient if you construct the factory by hand in a unit test:
$this->app->instance(PlatformInterface::class, new class implements PlatformInterface {
public function invoke(...): DeferredResult { /* return canned result */ }
});
This makes your domain services testable with zero network and zero coupling to Albert's response shape. If you log carbon footprint, add a stub that returns non-null metadata so your reporting path stays covered.
Observability decorator. Wrap PlatformInterface once to log model, token usage, carbon footprint, latency and outcome. Symfony AI ships a traceable platform for the Symfony profiler; in Laravel a 20-line decorator plus a log channel is the equivalent, and it's how you'll discover which model IDs are flaky — and which models actually populate the carbon-footprint metadata.
invoke() is lazy — try/catch won't catch transport errors. It returns a DeferredResult; the HTTP call happens when you call asText(), asVectors() or similar. Every failover/retry example you write around invoke() is wrong unless you resolve inside the try block. This is the most common bug with Symfony AI bridges in general and will bite you on day one. The same applies to metadata: carbon footprint is only populated after the deferred result resolves.
Carbon footprint metadata is per-release, per-call, and not guaranteed. v0.14.0 added it, but it may be absent for some models, some endpoints, or when the upstream omits the field. Read it defensively ($result->getMetadata()->get('...')), never assume a numeric value, and don't build a compliance report on an unfilled column. Verify the exact metadata key against the tag you installed — this is a brand-new feature with no published docs.
Pin against 0.14, not against blog posts. This package has moved from 0.8 to 0.14 in roughly the same amount of time it takes to read a tutorial. One star, zero dependents, stub README — treat it as pre-production. Pin exact versions, diff between tags before upgrading, and keep your own integration tests around the domain service so an upstream change fails loudly in CI rather than silently in production.
Capability declarations were added late. EMBEDDINGS only landed in v0.4.0. On older tags, supports()-style checks return false and embedding calls fail confusingly. Verify capabilities against the actual tag you installed, not against the docs or the model list.
Model IDs are yours to get right. Albert/OpenGateLLM serves its own set of open models; the bridge's model catalog will not know them. Pass exact IDs as returned by /v1/models. A typo typically surfaces as a 404 or 422 from the upstream, not a helpful PHP exception.
Base URL and /v1. OpenAI-compatible routes are /v1/chat/completions and /v1/embeddings. A missing or doubled /v1, or a trailing slash, produces 404s that look like auth failures. Confirm the exact string in one successful curl before filing a bug.
Don't assume OpenAI feature parity. OpenGateLLM advertises OpenAI compatibility, not identity. Tool/function calling, JSON mode, streaming SSE framing and token-usage field names vary per served model. Probe each feature you depend on against each model you use; wrap structured-output parsing in a validator, not a json_decode(). Carbon footprint follows the same rule — it's an Albert extension, not part of the OpenAI surface.
No Laravel ergonomics. No config publishing, no facade, no events, no artisan generators, no Eloquent integration. Everything is manual DI. If that friction is unacceptable for your team, Laravel's own first-party AI package is the pragmatic alternative — this bridge earns its place when the EU-hosted, sovereign constraint is the requirement, not when you just want OpenAI in Laravel.
Be a good citizen on the API. Public-sector infrastructure, shared quotas, likely per-key rate limits you won't find documented in the README. Cache aggressively, batch embeddings, back off on 429, and avoid retry storms from queued jobs without jitter.
Sovereignty is a starting point, not a guarantee. The "GDPR by design" pitch in the assessments is about hosting jurisdiction. You still own prompt/response logging, retention in your own DB, and whether the data you send is lawful to process. Audit your logging channels before you ship — this is the one place where a casual Log::info($prompt) creates the exact compliance problem the package was chosen to avoid. Conversely, carbon-footprint metadata is now a positive artifact you can retain for ESG reporting — capture it deliberately rather than letting it vanish into logs.
Extension points worth knowing. Custom PlatformFactory subclass if you need non-standard HTTP options (proxies, timeouts, custom headers); a PlatformInterface decorator for caching, logging, tracing, failover and carbon accounting; and your own model-ID constants class, since nothing upstream will keep them in sync for you.
How can I help you explore Laravel packages today?