Mathieu Bernard Réserver 30 min

Passerelle LLM : qui détient les clés, qui paie, qui choisit le modèle

Quand l’IA arrive dans plusieurs applications, les clés, les coûts et le choix des modèles deviennent un sujet de gouvernance. AI Gateway en fait un point de passage unique, sur un principe : la passerelle ne convertit rien.

Quand l’IA arrive dans plusieurs applications d’une même organisation, la DSI voit apparaître les mêmes symptômes partout : une clé de fournisseur copiée dans chaque projet, personne pour dire qui consomme quoi ni pour combien, un SDK par fournisseur, et un changement de modèle, pour une raison de coût, de qualité ou de conformité, qui oblige à toucher chaque application. Ce n’est pas d’abord un problème de développeur. C’est une question de gouvernance : qui détient les clés, qui paie et sur quel budget, qui décide du modèle utilisé.

Le projet AI Gateway, une passerelle open source écrite en Symfony, répond à ces questions en un seul point de passage. Le choix qui structure tout le projet tient en une phrase : la passerelle ne convertit rien. Ce principe a des conséquences directes sur ce qu’une DSI peut attendre d’un tel composant, en maintenance comme en liberté de choix des modèles, et sur ce qu’il ne lui dira pas.

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 :

  1. authentifier chaque requête avec une clé propre à la passerelle (aigw_…) ;
  2. journaliser chaque appel avec les tokens, le coût et la durée ;
  3. 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.

Ces trois fonctions recouvrent les trois questions de gouvernance posées plus haut : qui a le droit d’appeler un modèle, combien cela coûte et à qui l’imputer, qui décide du modèle utilisé. La troisième est la plus souvent sous-estimée. Avec des alias, passer d’un modèle ou d’un fournisseur à un autre devient une décision de configuration prise à un seul endroit, et non un chantier dans chaque application. Les clés des fournisseurs, elles, ne sortent plus de la passerelle : une application compromise expose une clé aigw_… révocable, pas la clé du compte 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.

Pour une DSI, c’est un argument de coût de possession. Un composant d’infrastructure placé sur le chemin de tous les appels d’IA ne doit pas devenir le goulot qui retarde l’adoption d’une nouvelle fonctionnalité d’un fournisseur, ni un chantier de maintenance à chaque évolution d’API. Moins il interprète, moins il coûte à faire vivre.

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.

EndpointAuthentificationFormat
POST /openai/v1/chat/completionsAuthorization: Bearer aigw_xxxOpenAI
POST /anthropic/v1/messagesx-api-key: aigw_xxxAnthropic

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.

La conséquence est à connaître avant de choisir ce type de composant. La liberté de changer de modèle sans toucher aux applications est réelle à l’intérieur d’une famille de format, pas au-delà. Si la stratégie de l’organisation suppose de pouvoir basculer d’un modèle OpenAI vers un Claude par simple configuration, cette passerelle ne le promet pas, et c’est assumé. Si elle suppose surtout de pouvoir mettre en concurrence les nombreux fournisseurs compatibles OpenAI, elle le permet sans perte.

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èlePrioritéPoids
deepseek150
qwen250

À 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.

Côté exploitation, les chaînes répondent à deux risques qu’une direction connaît bien : la panne d’un fournisseur et la dépendance à un seul. Répartir la charge entre deux modèles permet aussi de comparer leur coût et leur qualité sur du trafic réel plutôt que sur une démonstration. Le repli a cependant des limites dans la version actuelle, décrites dans la dernière section.

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. C’est aussi le choix le plus lisible en gouvernance : un seul service, une équipe propriétaire, une configuration à auditer, quelles que soient les applications qui l’appellent. Si plusieurs applications embarquent chacune le bundle, la vue d’ensemble se disperse.

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.

Ce modèle colle à la façon dont une DSI répartit les responsabilités. Elle fixe le cadre par équipe (modèles autorisés, budgets, débits) ; une équipe peut se restreindre davantage pour une clé donnée, jamais s’élargir. Le journal répond enfin à la question « qui consomme quoi », par équipe, par clé et par modèle, en dollars, ce qui permet d’imputer la dépense d’IA comme n’importe quelle autre. Et l’absence du contenu des messages est un choix de conformité : ce journal peut être consulté largement sans exposer les données échangées avec les modèles.

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 de model sans réencoder le reste.
  • Les en-têtes ne passent pas. Seuls Content-Type et l’authentification sont envoyés au fournisseur ; anthropic-version est 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_tokens et completion_tokens ; Anthropic nomme ses champs input_tokens et output_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. AuthEnforcer est déclarée final, donc on ne peut pas l’étendre, et le tag ai_gateway.auth_enforcer n’est lu nulle part dans l’extension. Le vrai point d’extension aujourd’hui, c’est KeyStoreInterface.
  • Un test vise une méthode qui n’existe plus. GatewaySseParsingTest appelle par réflexion une méthode privée extractStreamMetadataFromSseChunk, 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.

Pour une DSI qui envisagerait d’en faire son outil de pilotage des coûts, deux points pèsent plus que les autres. D’abord, un appel streamé peut être compté à zéro token : le budget n’est alors fiable que si l’usage remonte bien, ce qui se vérifie en rapprochant, sur ses propres flux, les tokens journalisés de la facture du fournisseur. Ensuite, la limite par minute ne protège pas en l’état : une application qui s’emballe n’est arrêtée que par les limites journalières et les budgets, eux-mêmes dépendants du décompte des tokens. Ce sont des points à faire vérifier avant la mise en service, pas des raisons d’écarter le composant : la version est une alpha, et la relecture dit précisément où regarder.

Les leçons

  • Centraliser l’accès aux LLM est une décision de gouvernance avant d’être une décision technique. Clés, budgets par équipe, journal des coûts et choix des modèles se règlent à un seul endroit, au lieu d’être dispersés dans chaque application.
  • 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. Devant une passerelle, ou n’importe quel composant d’infrastructure, c’est la première liste à demander.
  • 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). Un tableau de bord des coûts ne vaut que ce que valent les tokens qu’il reçoit : rapprochez-le de la facture avant de piloter avec.
  • « 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. Avant de retenir un composant open source, faites relire le code sur les fonctions dont vous dépendez, pas seulement la documentation.
En résumé : AI Gateway authentifie, journalise et résout des alias de modèles, et laisse passer tout le reste. Pour une DSI qui voit l’IA arriver dans plusieurs applications, c’est un moyen de reprendre la main sur les clés, les budgets par équipe et le choix des modèles, en un seul point. Ce refus de convertir rend la passerelle simple, fidèle aux formats natifs et peu exposée aux évolutions des API, au prix d’une interchangeabilité limitée à une famille de format et d’une visibilité partielle sur ce qui la traverse, à vérifier avant d’en faire l’outil de suivi des coûts. Le détail du projet est sur la page projet et le code sur GitHub.