AI Gateway : une passerelle LLM qui ne convertit rien
Quand plusieurs applications appellent des LLM, la même plomberie se répète partout. Le projet AI Gateway en fait de l’infrastructure, sur un principe : la passerelle ne convertit rien.

Quand plusieurs applications commencent à appeler des LLM, la même plomberie réapparaît partout : une clé de fournisseur copiée dans chaque projet, aucune idée de qui consomme quoi, un SDK par fournisseur, et un changement de modèle qui oblige à toucher chaque client. Le projet AI Gateway, une passerelle open source écrite en Symfony, s’attaque à ce problème une fois pour toutes. Et le choix qui structure tout le projet tient en une phrase : la passerelle ne convertit rien.
1. Le problème : des clés partagées et des modèles codés en dur
Le README pose le constat en quatre points, repris ici tels quels parce qu’ils forment le cahier des charges : partager des clés d’API entre projets, savoir qui utilise quoi, éviter de s’enfermer dans le SDK d’un fournisseur, changer de modèle sans mettre à jour chaque client.
Le réflexe habituel, c’est d’ajouter une couche d’abstraction dans le code : une librairie qui parle à tous les fournisseurs et expose une interface unique. Ça règle le problème du SDK, mais pas les autres. Les clés restent dans chaque application, les logs sont éparpillés, et changer de modèle reste un déploiement.
Le projet prend donc le problème par l’autre bout : en faire de l’infrastructure. Un service unique que toutes les applications appellent en HTTP, avec l’API compatible OpenAI qu’elles savent déjà parler. Pas de SDK de passerelle à importer : on change l’URL de base du client HTTP existant, et c’est tout.
Concrètement, la passerelle fait trois choses, et seulement trois :
- authentifier chaque requête avec une clé propre à la passerelle (
aigw_…) ; - journaliser chaque appel avec les tokens, le coût et la durée ;
- centraliser la configuration des modèles : les clients appellent un alias (
deepseek,claude-sonnet), la passerelle résout le vrai nom de modèle et la vraie clé du fournisseur.
2. La décision : un proxy transparent
La question de fond était : jusqu’où la passerelle doit‑elle comprendre ce qui la traverse ? La réponse retenue : le moins possible. Elle lit le corps JSON pour y trouver deux informations (le champ model et le drapeau stream), puis elle transmet le reste.
Voici tout ce que l’adaptateur HTTP modifie dans le corps de la requête :
$data = json_decode($request->rawBody, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \RuntimeException('JSON object body is required for transparent proxy');
}
$data['model'] = $request->model;
return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
Une ligne utile : $data[’model’] = $request->model. Les messages, les outils, les paramètres de température, le format de sortie : rien n’est relu, rien n’est reconstruit. L’autre changement, c’est l’en-tête d’authentification :
if ('anthropic' === $request->requestFormat) {
$options['headers']['x-api-key'] = $this->apiKey;
$options['headers']['anthropic-version'] = '2023-06-01';
} else {
$options['auth_bearer'] = $this->apiKey;
}
Côté réponse, c’est encore plus simple : le corps renvoyé par le fournisseur est retourné tel quel au client, y compris quand c’est une erreur. Un 400 de DeepSeek reste un 400 de DeepSeek, avec son message d’origine.
Entrée : Authorization: Bearer aigw_xxx
{"model":"deepseek", "messages":[...], "tools":[...]}
Sortie : Authorization: Bearer <clé du fournisseur>
{"model":"deepseek-v4-flash", "messages":[...], "tools":[...]}
Pourquoi cette sobriété ? Parce que chaque champ qu’une passerelle interprète devient un champ qu’elle doit maintenir. Les API des fournisseurs évoluent vite : nouveaux paramètres, nouveaux types de contenu, nouveaux formats d’outils. Un proxy qui ne lit pas ces champs n’a pas à suivre ces évolutions. Le jour où un fournisseur ajoute un paramètre, il passe à travers la passerelle sans qu’on ait rien à livrer.
Une passerelle qui comprend tout doit tout maintenir. Une passerelle qui ne comprend que model n’a presque rien à suivre.
3. Deux endpoints natifs plutôt qu’une traduction
La tentation suivante, c’était l’endpoint universel : un seul format en entrée (celui d’OpenAI, devenu le standard de fait), et la passerelle traduit vers Anthropic quand le modèle est un Claude. C’est ce que font beaucoup de passerelles, et c’est séduisant pour le client.
Le projet a retenu l’inverse : deux endpoints, chacun dans son format natif, avec son propre en-tête d’authentification.
| Endpoint | Authentification | Format |
|---|---|---|
POST /openai/v1/chat/completions | Authorization: Bearer aigw_xxx | OpenAI |
POST /anthropic/v1/messages | x-api-key: aigw_xxx | Anthropic |
Les deux contrôleurs ne diffèrent que par la façon d’extraire la clé. Le contrôleur OpenAI lit l’en-tête Authorization, le contrôleur Anthropic lit x-api-key, et tous deux délèguent à la même méthode avec un format explicite :
#[Route('/anthropic/v1/messages', name: 'ai_gateway_messages', methods: ['POST'])]
public function messages(Request $request): Response|StreamedResponse
{
return $this->handleRequest($request, $this->extractApiKeyHeader($request), 'anthropic');
}
Et si un client appelle un modèle Anthropic par l’endpoint OpenAI ? La passerelle refuse, au lieu de réécrire la requête :
if ($request->requestFormat !== $resolution->providerFormat) {
throw GatewayException::invalidRequest(sprintf(
'Model "%s" uses provider format "%s" and cannot be called through "%s" endpoint.',
$request->model,
$resolution->providerFormat,
$request->requestFormat,
));
}
Ce qu’on perd, il faut le dire clairement. Un client écrit pour OpenAI ne peut pas basculer sur Claude en changeant seulement l’alias : il doit parler le format Anthropic. La liste des modèles est donc filtrée par format (/openai/v1/models et /anthropic/v1/models ne renvoient pas les mêmes alias). L’interchangeabilité totale des modèles n’existe qu’à l’intérieur d’une même famille de format.
Ce qu’on gagne : aucune perte de fidélité. Une traduction entre formats est toujours approximative. Les blocs de contenu d’Anthropic, ses outils, son streaming par événements typés n’ont pas d’équivalent exact chez OpenAI, et inversement. Chaque traduction est une source de bugs subtils : un champ ignoré, un type de bloc mal converti, un usage de tokens mal remonté. En refusant de traduire, la passerelle élimine toute cette catégorie de problèmes. Et côté fournisseurs, la catégorie OpenAI couvre déjà large : le README cite OpenAI, DeepSeek, OpenRouter, Groq et tout fournisseur compatible.
4. Le streaming SSE transmis tel quel
Le streaming était le test de vérité du principe. Une passerelle qui reconstruit les réponses doit décoder chaque événement SSE, le transformer et le réémettre. Ici, l’adaptateur se contente de relayer les octets :
// Stream raw SSE bytes — transparent passthrough
foreach ($this->httpClient->stream($response) as $chunk) {
yield $chunk->getContent();
}
Et le contrôleur les pousse vers le client au fil de l’eau, en désactivant tout ce qui pourrait bufferiser en route (le tampon de sortie PHP, et le buffering d’un éventuel reverse proxy via X-Accel-Buffering) :
header('X-Accel-Buffering: no');
if (ob_get_level() > 0) ob_end_clean();
foreach ($this->gateway->chatStream($request) as $chunk) {
echo $chunk;
if (ob_get_level() > 0) ob_flush();
flush();
}
Reste un problème : si la passerelle ne lit pas le flux, comment journalise-t‑elle les tokens consommés ? La solution est d’observer sans modifier. Pendant que les octets passent, le Gateway accumule une copie dans un tampon, y cherche les lignes data: complètes et en extrait l’identifiant, le modèle et l’usage quand ils apparaissent. Le chunk, lui, est transmis sans attendre :
if (isset($decoded['usage']) && is_array($decoded['usage'])) {
$finalUsage = new Usage(
(int) ($decoded['usage']['prompt_tokens'] ?? 0),
(int) ($decoded['usage']['completion_tokens'] ?? 0),
(int) ($decoded['usage']['total_tokens'] ?? 0),
);
}
// ...
yield $chunk;
Le tampon est plafonné à 64 Ko pour ne pas grossir indéfiniment si une ligne ne se termine jamais. La journalisation et le décompte du budget se font une fois le flux terminé.
5. Les chaînes de modèles : priorité et poids
Un alias peut désigner un modèle, ou une chaîne de modèles. Une chaîne combine deux mécanismes : la priorité pour le repli, le poids pour la répartition de charge. L’exemple du README :
| Modèle | Priorité | Poids |
|---|---|---|
deepseek | 1 | 50 |
qwen | 2 | 50 |
À priorité égale, tirage pondéré ; une priorité plus basse sert de repli. Le code regroupe les étapes par niveau de priorité, trie les niveaux, puis tire au sort dans chaque niveau en retirant les modèles qui échouent :
foreach ($tiers as $tierSteps) {
$remaining = $tierSteps;
while ([] !== $remaining) {
$pickedIndex = $this->pickWeightedStepIndex($remaining);
$picked = $remaining[$pickedIndex];
try {
$response = $this->executeSingle($request, $picked['resolution']);
// ... coût calculé avec la tarification du modèle choisi
return new GatewayResponse(/* ... */);
} catch (\Throwable $e) {
$lastException = $e;
unset($remaining[$pickedIndex]);
$remaining = array_values($remaining);
}
}
}
Le tirage lui-même est un classique : on additionne les poids, on tire un entier avec random_int(1, $total), on avance jusqu’à dépasser le tirage. Côté tableau de bord, un ChainWeightNormalizer ramène les poids saisis à un total de 100, en distribuant les restes d’arrondi aux plus grandes fractions, pour que l’affichage reste lisible.
Un détail compte : avant tout tirage, les étapes sont filtrées par format. Une chaîne peut mélanger des modèles OpenAI et Anthropic ; appelée depuis l’endpoint OpenAI, elle ne garde que les premiers. Les chaînes sont donc les seuls alias listés dans les deux formats. Et le journal enregistre à la fois l’alias demandé (la chaîne) et le modèle effectivement tiré, pour savoir après coup qui a répondu.
6. Bundle ou standalone : un seul dépôt
La passerelle tourne de deux façons, et la différence ne porte que sur l’endroit où vit l’application Symfony.
En standalone, c’est un conteneur Docker (FrankenPHP et SQLite) que n’importe quelle application appelle en HTTP, quel que soit son langage. Le Dockerfile ne contient pas d’application dédiée : il crée un squelette Symfony vierge et y installe le bundle depuis les sources du dépôt.
FROM dunglas/frankenphp:1-php8.4-alpine
RUN install-php-extensions pdo_sqlite
ENV DATABASE_URL=sqlite:////runtime/data/auth.db
RUN composer create-project symfony/skeleton:^8.0 /runtime --no-interaction --no-progress
RUN composer config repositories.ai-gateway path /bundle \
&& composer require ai-gateway/ai-gateway-bundle:@dev --no-interaction --no-progress --no-scripts
Au démarrage, le script d’entrée crée la base SQLite si elle n’existe pas et met le schéma à jour. Les données vivent dans un volume /runtime/data, et un healthcheck interroge /v1/health toutes les 30 secondes. Une GitHub Action publie l’image sur le registre de conteneurs de GitHub à chaque push sur main et à chaque tag.
En bundle, la passerelle s’installe avec composer require dans une application Symfony existante. Elle partage alors la connexion Doctrine de l’application et monte ses routes à côté des siennes. L’extension du bundle enregistre ses entités et ses templates dans la configuration de l’hôte, et le préfixe des routes est configurable.
La recommandation du README reste de commencer par le standalone : plus simple à déployer, indépendant du langage, et la configuration de la passerelle reste séparée du code applicatif.
7. Clés, équipes, budgets
Les clés de la passerelle ne sont jamais stockées en clair. À la création, on génère 24 octets aléatoires, on garde le hash SHA-256 et un préfixe de 8 caractères pour l’affichage :
$raw = 'aigw_' . bin2hex(random_bytes(24));
$hash = hash('sha256', $raw);
$entity->setTokenHash($hash);
$entity->setTokenPrefix(substr($raw, 0, 8));
La clé brute n’est affichée qu’une fois. À chaque requête, l’authentificateur hashe le jeton reçu, cherche la clé, vérifie qu’elle est active et non expirée, puis fusionne les règles de son équipe avec ses propres surcharges. La règle de fusion est toujours la plus restrictive : intersection des modèles autorisés, union des modèles bloqués, minimum des budgets et des limites de débit.
return min((float) $teamLimit, (float) $keyLimit);
Le tableau de bord empêche d’ailleurs de configurer une clé plus permissive que son équipe : un budget de clé supérieur à celui de l’équipe, ou un modèle hors du périmètre de l’équipe, est refusé à la saisie. Ensuite, l’AuthEnforcer vérifie dans l’ordre le modèle, le budget journalier et mensuel en dollars, puis les limites de requêtes par jour et par minute. Le coût se calcule à partir de la tarification saisie pour chaque alias, en dollars par million de tokens d’entrée et de sortie.
Une requête bloquée est journalisée elle aussi, avec la raison du refus. Le journal ne contient que des métadonnées : alias, modèle résolu, fournisseur, statut, durée, tokens, coût, clé, équipe. Jamais le contenu des messages.
8. Étendre l’authentification
En standalone, la passerelle gère ses propres clés. En bundle, l’intention est de pouvoir remplacer ce mécanisme par l’authentification de l’application hôte : les mêmes utilisateurs, les mêmes sessions, et les budgets calculés à partir du rôle de l’utilisateur. Le README montre un SessionAuthEnforcer qui lit l’utilisateur courant via le composant Security et ajuste son budget journalier selon qu’il est premium ou non, à enregistrer avec un tag de service ai_gateway.auth_enforcer.
Le point d’extension réel existe déjà à deux niveaux : le stockage des clés passe par une KeyStoreInterface, et l’extension du bundle ne pose son implémentation Doctrine par défaut que si l’application n’en a pas déclaré une :
if (!$container->hasAlias(KeyStoreInterface::class) && !$container->hasDefinition(KeyStoreInterface::class)) {
$container->setAlias(KeyStoreInterface::class, OrmKeyStore::class);
}
Même logique pour GatewayInterface. Une application peut donc fournir son propre magasin de clés, adossé à sa propre entité utilisateur, sans toucher au reste de la passerelle. Le point est repris plus bas, parce que l’exemple du README va plus loin que le code actuel.
9. Ce que la passerelle refuse de faire
Le README contient une section qui, à mon sens, compte autant que la liste des fonctionnalités : ce que la passerelle ne fait pas.
- Pas d’abstraction des SDK de fournisseurs. Le client garde son SDK habituel ; la passerelle n’en impose aucun.
- Pas de transformation des requêtes et des réponses, au-delà du modèle et de la clé.
- Pas de normalisation des sorties structurées ni des appels de fonctions. Chaque format garde ses conventions.
- Pas de cache.
Chacune de ces absences est une fonctionnalité qu’on aurait sans doute demandée au projet, et chacune aurait obligé la passerelle à comprendre le contenu des échanges. Le cache, par exemple, exige de décider quand deux requêtes sont « les mêmes », donc d’interpréter les messages, les paramètres, les outils. C’est exactement ce que le projet a voulu éviter.
La démonstration la plus parlante est l’intégration avec Symfony AI. Comme la passerelle est compatible OpenAI, il suffit de la déclarer comme un fournisseur OpenAI avec une autre URL de base :
ai:
llms:
open_ai:
my_gateway:
apikey: '%env(AI_GATEWAY_KEY)%'
base_url: 'http://localhost:8080/openai/v1'
model: 'deepseek'
Le code Symfony AI ne sait pas qu’une passerelle existe. Il appelle deepseek, la passerelle résout vers deepseek-v4-flash, et la réponse revient intacte.
10. Ce que révèle la relecture du code
Une relecture du code, menée pour cet article, fait apparaître plusieurs endroits où la promesse de transparence, ou celle du README, va plus loin que l’implémentation de la version 0.1.0-alpha. Autant les écrire ici.
- Le corps n’est pas transmis à l’octet près. Il est décodé en tableau PHP, puis réencodé. Effet de bord connu de PHP : un objet vide
{}devient un tableau vide[]. Un schéma d’outil avec"properties": {}ressort donc modifié. La correction naturelle serait de remplacer uniquement la valeur demodelsans réencoder le reste. - Les en-têtes ne passent pas. Seuls
Content-Typeet l’authentification sont envoyés au fournisseur ;anthropic-versionest fixé à2023-06-01. Un en-tête de fonctionnalité bêta envoyé par le client est perdu. Dans l’autre sens, les en-têtes de réponse du fournisseur ne sont pas relayés non plus. - Le repli des chaînes ne couvre que les exceptions. Une erreur HTTP du fournisseur (un 500, par exemple) est renvoyée telle quelle au client, conformément au principe de transparence, mais elle ne déclenche donc pas le passage au modèle suivant. Et en streaming, la chaîne tire un modèle dans le premier niveau et s’y tient : pas de repli du tout.
- L’usage en streaming n’est lu qu’au format OpenAI. L’extraction cherche
prompt_tokensetcompletion_tokens; Anthropic nomme ses champsinput_tokensetoutput_tokens, que seul le chemin non streamé sait lire. Et comme la passerelle ne modifie pas la requête, elle ne demande pas l’usage au fournisseur quand celui-ci ne l’envoie que sur option : une requête streamée peut alors être journalisée à zéro token, donc à zéro dollar pour le budget. - La limite par minute vit en mémoire. Le compteur à fenêtre glissante est un simple tableau PHP. L’image Docker lance FrankenPHP avec
php_server, sans mode worker : ce tableau ne survit pas d’une requête à l’autre. La limite par jour, elle, s’appuie sur la base et fonctionne. - L’exemple d’extension du README ne compile pas en l’état.
AuthEnforcerest déclaréefinal, donc on ne peut pas l’étendre, et le tagai_gateway.auth_enforcern’est lu nulle part dans l’extension. Le vrai point d’extension aujourd’hui, c’estKeyStoreInterface. - Un test vise une méthode qui n’existe plus.
GatewaySseParsingTestappelle par réflexion une méthode privéeextractStreamMetadataFromSseChunk, alors que l’extraction SSE est désormais intégrée àstreamSingleAndTrack. Les 41 tests du dépôt couvrent bien la tarification, la fusion des règles, la normalisation des poids et l’authentification, mais ce test-là décrit une version antérieure du code.
Aucun de ces points ne remet en cause la décision de départ. Plusieurs en sont même la conséquence directe : une passerelle qui ne touche pas à la requête ne peut pas, par construction, ajouter l’option qui ferait remonter l’usage. C’est le prix du principe, et il vaut mieux le connaître.
Les leçons
- Définir ce qu’un composant ne fait pas est aussi structurant que ce qu’il fait. La liste des refus a guidé chaque décision plus sûrement que la liste des fonctionnalités.
- Refuser une requête vaut mieux que la traduire à moitié. Une erreur explicite sur le mauvais endpoint coûte une ligne de documentation ; une conversion approximative coûte des bugs invisibles.
- Observer n’est pas modifier. Le streaming peut être journalisé sans être reconstruit, à condition de lire une copie du flux et de laisser passer l’original.
- Le principe a un coût, il faut le chiffrer. La transparence limite ce que la passerelle peut savoir (l’usage en streaming) et ce qu’elle peut décider (le repli sur une erreur HTTP).
- « Transparent » se vérifie au niveau de l’octet. Un décodage suivi d’un réencodage paraît neutre, et ne l’est pas.
- Un README est du code qu’on ne teste pas. Il peut promettre un point d’extension que l’implémentation ne fournit pas encore.