Skip to main content
La plupart des applications d’IA commencent par appeler directement une API de modèle. Cela fonctionne bien pour les prototypes, mais dès que plusieurs applications, services ou clients ont besoin d’y accéder, les appels directs aux fournisseurs deviennent plus difficiles à gérer. Chaque service a besoin d’une clé fournisseur, chaque client doit apprendre les comportements spécifiques au fournisseur, et chaque équipe finit par résoudre l’authentification, les limites et l’observabilité de manière légèrement différente. Une passerelle LLM nous offre un endroit unique pour authentifier les appelants, appliquer des limites de débit, cacher les clés des fournisseurs en amont, enregistrer la télémétrie et conserver une API stable pour nos propres applications. Dans ce tutoriel, nous allons en construire une en Rust avec Axum, Postgres, SQLx et l’API Venice AI. À la fin, vous disposerez d’une passerelle qui expose un point de terminaison /v1/chat/completions compatible OpenAI, accepte vos propres jetons bearer, transfère les requêtes vers Venice, prend en charge les réponses en streaming et émet des spans et métriques OpenTelemetry utiles. Vous souhaitez voir l’implémentation complète du code ? Consultez le dépôt GitHub.

Prérequis

  • Rust 1.92+
  • Docker et Docker Compose
  • Une clé API Venice
  • curl
  • Une familiarité de base avec les services web en Rust
Avant de commencer, exportez votre clé API Venice :
Nous n’exposerons jamais cette clé aux applications clientes. La passerelle la conservera côté serveur et les clients s’authentifieront avec des clés API spécifiques à la passerelle.

Ce que nous construisons

L’implémentation de référence est un petit service Rust composé de quelques parties clairement définies : Schéma d'architecture montrant un client appelant la passerelle Rust, Postgres, Venice AI et OpenTelemetry Un client envoie une requête compatible OpenAI à la passerelle. La passerelle authentifie l’appelant, vérifie les limites de débit, transfère la requête à Venice et enregistre la télémétrie tout au long du processus. Dans le cadre de la passerelle, nous veillerons à ce que ce service reste évolutif horizontalement, avec la plus faible surface d’attaque possible en ce qui concerne l’API elle-même. Il y a plusieurs raisons à cela — l’une d’elles étant principalement que si vous avez un très haut débit par exemple, vous voudrez presque certainement utiliser des répliques (c’est-à-dire lancer plus d’une instance du même service). Cela signifie que si ce n’est pas déjà le cas, architecturalement vous voudrez placer votre service original et ses répliques derrière un équilibreur de charge afin que si un conteneur ou un service tombe en panne, l’ensemble du service ne subisse pas de panne. De plus, nous supposerons également que nous possédons d’une certaine manière la création de clés API, bien que le service de passerelle ne devrait pas les émettre de manière isolée. Cela sera représenté par une table Postgres que nous initialisons lorsqu’elle est utilisée localement. En production, cela serait généralement géré par le service d’authentification. Bien qu’il soit possible de gérer la création d’une clé API en amont pour chaque utilisateur qui utilise votre passerelle LLM, en pratique ce n’est généralement pas conseillé. En déléguant cette responsabilité au service en amont, vous déléguez également tout contrôle que vous auriez normalement — ce qui signifie que vous ne pouvez pas appliquer pleinement des choses comme la limitation de débit et les plafonds de dépenses. L’arborescence source reste intentionnellement petite :
Sans plus tarder, commençons la construction.

Création du service Rust

Commencez par un nouveau projet binaire Rust :
Ajoutez les dépendances dont nous avons besoin dans Cargo.toml — les explications sont ajoutées dans l’extrait de code :

Chargement de la configuration

La passerelle lit tout depuis les variables d’environnement. Pour du code d’infrastructure comme celui-ci, les variables d’environnement sont un bon choix par défaut car le même binaire peut fonctionner localement, dans Docker Compose ou dans un environnement hébergé sans nécessiter un format de fichier de configuration distinct. De plus, de nombreux fournisseurs vous permettront de stocker vos propres variables d’environnement en tant que secrets dans leur propre runtime de conteneur. C’est souvent beaucoup plus sûr que d’essayer d’utiliser quelque chose comme dotenv (ou dotenvy en Rust, puisque la crate dotenv originale est en grande partie dépréciée). Créez src/config.rs :
Bien qu’il y ait beaucoup de valeurs possibles analysées ici à partir des variables d’environnement, en général, vous n’en avez besoin que de deux :
  • L’URL de la base de données
  • Votre clé API Venice
Deux valeurs par défaut sont importantes ici. VENICE_BASE_URL pointe vers https://api.venice.ai/api/v1, et CAPTURE_GENAI_CONTENT vaut false par défaut, de sorte que le contenu des prompts n’est pas enregistré à moins que vous ne l’activiez intentionnellement. Cette seconde valeur par défaut est la plus importante. Une passerelle peut voir chaque prompt et chaque réponse qui la traversent, mais l’observabilité ne devrait pas devenir automatiquement une capture de contenu. Dans la plupart des systèmes de production, le nombre de jetons, la latence, les noms des modèles, les codes de statut et les métadonnées de facturation sont suffisants pour les opérations. D’une manière générale, la journalisation des prompts et des conversations en production peut non seulement constituer un risque pour la vie privée — elle peut également représenter un risque de stockage. Les ajouter signifie créer des spans et des traces avec un niveau de cardinalité extrêmement élevé (c’est-à-dire l’unicité des données dans un ensemble de données). Cela peut rendre la recherche dans vos données d’observabilité très coûteuse, en plus de potentiellement nuire aux performances lors de la recherche dans les données.

Création du schéma de base de données

Ensuite, créez migrations/0001_api_keys.sql. Nous ne stockerons que les 12 premiers caractères de chaque clé API de passerelle en tant que préfixe de recherche, ainsi que le hachage SHA-256 de la clé complète. Cela permet à la passerelle de trouver rapidement une ligne candidate sans stocker les identifiants bruts. Le préfixe n’est pas secret. Il existe pour l’indexation. Le hachage est ce qui prouve que l’appelant a présenté la clé complète. C’est la même forme de base utilisée par de nombreux systèmes de clés API : afficher la clé brute une seule fois, stocker une représentation non réversible et conserver un court préfixe pour la recherche et les flux de travail de support.
Ajoutez maintenant une table pour la limitation de débit à fenêtre fixe :
Ce schéma est petit, mais il nous donne les invariants importants :
  • Les clés API ne sont jamais stockées en clair.
  • Les paramètres de limitation de débit doivent être positifs.
  • Les clés révoquées ne peuvent pas rester actives.
  • Une fenêtre de limitation de débit est identifiée de manière unique par la clé, l’heure de début et la durée de la fenêtre.
Conserver ces invariants dans Postgres est utile car chaque appelant doit passer par cet état de base de données. Même si nous ajoutons plus tard une API d’administration, un job de rotation de clés en arrière-plan ou une migration qui importe des clés depuis un autre système, la base de données rejette toujours les états impossibles comme une clé active avec un horodatage de révocation.

Construction du client Venice

Ensuite, nous allons créer src/venice.rs. Le client n’a besoin de connaître que l’URL des complétions de chat en amont, la clé API Venice et le nombre de fois qu’il doit réessayer en cas d’échec transitoire. Garder ce wrapper petit est intentionnel — la passerelle ne devrait pas réimplémenter l’ensemble de l’API de Venice. À un niveau de base, le travail de la passerelle consiste à attacher l’identifiant côté serveur, à appliquer un délai d’attente, à réessayer les requêtes qu’il est sûr de réessayer et à renvoyer la réponse en amont sous une forme que le routeur peut transférer.
Pour les requêtes sans streaming, nous pouvons réessayer les erreurs de connexion, les délais d’attente et les codes de statut HTTP transitoires :
Les nouvelles tentatives ne sont appliquées qu’au chemin sans streaming. Une fois qu’une réponse en streaming a commencé, réessayer à l’intérieur de la passerelle pourrait dupliquer une sortie partielle ou perturber les clients qui ont déjà reçu des morceaux. Pour le streaming, le meilleur choix par défaut est de faire remonter l’erreur et de laisser l’appelant décider s’il faut réessayer l’ensemble de la requête. Pour le streaming, nous créons un EventSource à partir de la même requête :
Le point de terminaison des complétions de chat de Venice est compatible OpenAI, la passerelle peut donc accepter un corps familier :
Vous pouvez remplacer le modèle par n’importe quel modèle capable de chat disponible dans votre compte Venice. Remarquez que le corps de la requête est toujours un serde_json::Value. C’est un choix de compatibilité délibéré. Si nous modélisons chaque champ possible de la complétion de chat en Rust, nous devons maintenir la passerelle à jour chaque fois que l’API en amont ajoute une option utile. En n’analysant ailleurs que ce dont nous avons besoin, nous laissons passer de nouveaux paramètres Venice sans nécessiter une nouvelle version de la passerelle.

Partage de l’état de l’application

Créez src/state.rs :
Axum clone l’état dans les handlers, donc l’état lui-même doit être peu coûteux à cloner. PgPool est déjà un handle de pool partagé, et Arc<Config> maintient également la configuration peu coûteuse. Cela donne à chaque handler accès aux trois mêmes choses : configuration immuable, connexions de base de données en pool et client Venice. Les garder dans un seul AppState simplifie également les tests plus tard, car les handlers reçoivent leurs dépendances via l’état Axum au lieu de lire des variables globales.

Authentification des clés API de la passerelle

Le client envoie sa clé de passerelle ainsi :
Créez src/auth.rs et implémentez un extracteur Axum. L’extracteur permet aux handlers protégés de déclarer qu’ils nécessitent une clé authentifiée :
Le flux d’authentification réel est :
  1. Analyser le jeton bearer.
  2. Prendre les 12 premiers octets comme préfixe de clé.
  3. Hacher le jeton candidat complet avec SHA-256.
  4. Charger la ligne de clé active par préfixe.
  5. Comparer le hachage stocké et le hachage candidat en temps constant.
Cela permet de séparer les identifiants en amont et ceux de la passerelle. Vos applications de production peuvent effectuer une rotation des clés de la passerelle sans changer la clé API Venice, et la clé Venice n’a jamais besoin de quitter le serveur. Le pattern extracteur est utile car l’authentification devient partie intégrante de la signature de type du handler. Une route qui accepte AuthenticatedApiKey ne peut pas accidentellement sauter l’authentification à l’intérieur du corps de la fonction ; Axum doit construire cette valeur avant que le handler ne s’exécute. Cela rend le chemin protégé facile à auditer.

Ajout de limites de débit à fenêtre fixe

Créez src/rate_limit.rs. Le limiteur de débit utilise une seule instruction SQL pour insérer une nouvelle fenêtre ou incrémenter la fenêtre existante :
La clause WHERE api_key_rate_limit_windows.request_count < $3 est le point important. Lorsque la fenêtre est déjà pleine, Postgres ne met pas à jour la ligne et RETURNING ne produit aucune ligne. Le handler peut transformer cela en une réponse 429 Too Many Requests avec un en-tête Retry-After. Une fenêtre fixe n’est pas le limiteur de débit le plus sophistiqué, mais elle est facile à expliquer, facile à inspecter et suffisamment bonne pour un tutoriel de passerelle. Le compromis est que le trafic peut se concentrer autour des limites de fenêtre. Si vous avez besoin d’un comportement plus fluide à grande échelle, un limiteur à seau à jetons ou à fenêtre glissante soutenu par Redis est une étape naturelle suivante.

Retour d’erreurs de style OpenAI

Créez src/error.rs et faites en sorte que les erreurs de l’application implémentent IntoResponse :
Pour les erreurs générées par la passerelle, renvoyez un corps JSON formaté comme les erreurs courantes des API de modèles :
Pour les erreurs Venice en amont, préservez le code de statut et le corps en amont. Cela facilite grandement le débogage pour les clients car les erreurs de validation au niveau du fournisseur ressemblent toujours à des erreurs de validation au niveau du fournisseur. Cette séparation permet de garder la passerelle honnête quant à l’origine d’une erreur. Si la passerelle rejette une requête parce que le jeton bearer est manquant ou que l’appelant a dépassé la limite, elle renvoie une erreur formatée par la passerelle. Si Venice rejette la requête du modèle, nous préservons le corps en amont afin que les développeurs clients puissent voir le message de validation du fournisseur au lieu d’un échec de proxy générique.

Construction du routeur

Nous pouvons maintenant câbler les routes HTTP dans src/router.rs :
Le handler de chat commence par exiger un AuthenticatedApiKey. Si l’authentification échoue, Axum n’entre jamais dans le corps du handler :
La passerelle ne valide que les champs dont elle a besoin pour son propre comportement : model, messages et stream. Tout le reste du corps JSON passe à Venice. Cela maintient la passerelle compatible avec les fonctionnalités du fournisseur que vous pourriez vouloir utiliser plus tard. Le handler rend également explicites les deux modes de réponse. Les requêtes sans streaming attendent que Venice renvoie une réponse JSON complète, puis enregistrent les métadonnées de réponse avant d’envoyer les octets en aval. Les requêtes en streaming renvoient immédiatement un corps text/event-stream soutenu par un flux asynchrone. Cette séparation garde le chemin sans streaming simple tout en donnant au chemin de streaming suffisamment de contrôle pour observer les morceaux au fur et à mesure qu’ils passent.

Prise en charge des réponses en streaming

Les complétions de chat en streaming utilisent les server-sent events. Venice envoie des données SSE, et la passerelle relaie ces données au client. La passerelle doit éviter de mettre en mémoire tampon l’ensemble du flux car cela irait à l’encontre du but même du streaming. Les utilisateurs se soucient du temps avant le premier jeton, pas seulement du temps avant le jeton final. En transférant chaque événement en amont dès qu’il arrive, les clients peuvent afficher une sortie partielle pendant que le modèle est encore en train de générer. Créez src/sse.rs :
Chaque message est encodé à nouveau au format SSE :
Cela préserve l’expérience client attendue par les SDK compatibles OpenAI : les morceaux arrivent sous forme d’événements data: ..., et le flux se termine par data: [DONE]. L’observateur de flux est également l’endroit où nous pouvons collecter des métadonnées sans changer ce que voit le client. Chaque morceau est transféré au format SSE, mais la passerelle peut toujours surveiller les identifiants de réponse, les raisons de fin, l’utilisation des jetons, les champs de coût et les informations de temporisation à mesure que ces morceaux passent.

Enregistrement de la télémétrie GenAI

Les passerelles sont utiles parce que chaque requête passe par un seul endroit. Cela en fait un endroit idéal pour enregistrer le modèle, la latence, l’utilisation des jetons, les raisons de fin, le coût de facturation et les temporisations de streaming. Créez src/telemetry.rs et commencez par analyser la requête :
Créez ensuite un span en utilisant les attributs sémantiques GenAI :
Lorsqu’une réponse sans streaming revient, désérialisez les champs de métadonnées de réponse connus en structs. La passerelle transfère toujours les octets d’origine au client, mais la télémétrie n’a pas besoin de parcourir un JSON arbitraire. Le journal de facturation utilise l’UUID de la clé de passerelle plutôt que le jeton bearer en clair, et l’ID de la requête provient de l’id de la réponse de Venice :
Pour les réponses en streaming, enregistrez le temps jusqu’au premier morceau et le temps entre les morceaux de sortie au fur et à mesure que le flux SSE est relayé. Ces métriques sont particulièrement utiles lorsque vous vous souciez de la latence perçue, et pas seulement du temps total de requête. La télémétrie est ce qui fait qu’une passerelle devient plus qu’un simple proxy. Une fois que les spans incluent le modèle demandé, le modèle en amont, le nombre de jetons, les raisons de fin, le statut et les journaux de facturation par clé, vous pouvez répondre à des questions opérationnelles pratiques : quels clients dépensent le plus, quels modèles sont les plus lents, si le streaming améliore la latence perçue et si les erreurs proviennent de l’authentification, des limites de débit, du transport ou du fournisseur du modèle.

Démarrage du serveur

Maintenant, câblez tout ensemble dans src/main.rs :
Au démarrage, la passerelle :
  1. Lit la configuration.
  2. Initialise la télémétrie.
  3. Se connecte à Postgres.
  4. Exécute les migrations SQLx.
  5. Construit l’état partagé de l’application.
  6. Démarre le serveur Axum.
Exécuter les migrations au démarrage est pratique pour ce tutoriel car docker compose up peut amener toute la pile à un état fonctionnel. Dans un déploiement de production plus important, vous préférerez peut-être exécuter les migrations comme une étape de release distincte afin que les changements de schéma soient examinés et appliqués avant le démarrage des nouvelles instances de la passerelle.

Initialisation d’une clé de passerelle locale

Pour le développement local, créez scripts/seed_api_key.sh. Le script insère une clé API de passerelle dans Postgres en stockant son préfixe et son hachage SHA-256 :
La clé locale par défaut est :
Pour un déploiement réel, générez des clés aléatoires plus longues, affichez-les une fois à l’appelant et ne stockez que le hachage. Le script d’initialisation est intentionnellement ennuyeux car les identifiants locaux doivent être faciles à recréer. La version de production est l’endroit où vous ajouteriez une génération de clés plus robuste, une journalisation d’audit, une expiration et un flux d’affichage unique.

Exécution locale

Pour exécuter localement, nous utiliserons Docker Compose pour démarrer à la fois la passerelle et Postgres. Cela garde le tutoriel reproductible : les lecteurs n’ont pas besoin d’une base de données configurée manuellement, et la passerelle peut utiliser la même forme de DATABASE_URL qu’elle utiliserait dans un déploiement conteneurisé.
Nous aurons également besoin d’un petit Dockerfile qui compile le binaire Rust et le copie dans une image d’exécution plus petite :
Pour exécuter la pile, utilisez la commande suivante :
N’oubliez pas que vous pouvez également l’exécuter en mode détaché avec le flag -d si vous voulez utiliser votre terminal pour d’autres choses après (puis utilisez docker compose down pour le supprimer). Dans un autre terminal, initialisez la clé de passerelle de développement :
Si votre machine exécute déjà Postgres sur le port 5432, supprimez le mappage de port hôte pour le service Postgres de Compose. La passerelle n’a besoin d’atteindre Postgres que sur le réseau interne de Docker. L’important à garder à l’esprit est que la clé API Venice n’appartient qu’à l’environnement de la passerelle. Les requêtes des clients doivent utiliser la clé de passerelle initialisée. Cette séparation est tout l’intérêt de placer une passerelle devant le fournisseur du modèle.

Test de la passerelle

D’abord, vérifiez la santé :
Vous devriez voir :
Envoyez maintenant une requête de complétion de chat sans streaming :
La réponse devrait ressembler à une complétion de chat compatible OpenAI :
Pour le streaming :
Vous devriez voir des morceaux SSE :
Le dépôt inclut également un script de test de fumée :
Pour les vérifications locales de qualité du code, exécutez :
Tester les deux modes de réponse est important car ils sollicitent différents chemins de proxy. Le test sans streaming prouve que l’authentification, la limitation de débit, le transfert en amont et la télémétrie de réponse JSON fonctionnent. Le test de streaming prouve que la passerelle peut maintenir une connexion SSE ouverte et transférer les morceaux sans mettre d’abord en mémoire tampon la réponse finale.

Étendre cette passerelle

Cette passerelle est intentionnellement petite, mais elle vous offre une base solide. De bonnes prochaines étapes incluent :
  • Ajouter des budgets par sujet et des limites de dépenses mensuelles.
  • Prendre en charge plusieurs fournisseurs en amont derrière la même API compatible OpenAI.
  • Stocker les métadonnées des requêtes pour les journaux d’audit tout en gardant la journalisation des prompts désactivée par défaut.
  • Ajouter une API d’administration pour créer, révoquer et faire tourner les clés de passerelle.
  • Ajouter des listes d’autorisation de modèles par clé API.
  • Ajouter Redis ou un autre magasin partagé si vous avez besoin d’une limitation de débit à latence plus faible sur plusieurs instances de passerelle.
L’idée principale de conception est de garder la politique dans la passerelle et l’inférence dans Venice. Cela permet aux applications clientes d’utiliser une API familière tandis que votre plateforme conserve le contrôle sur les clés, l’utilisation, les limites et l’observabilité.

Pour finir

Merci d’avoir lu ! J’espère que cela vous a aidé à comprendre comment construire une passerelle LLM pratique en Rust sans en faire un énorme projet de plateforme. En combinant Axum, Postgres, SQLx, OpenTelemetry et l’API de complétions de chat compatible OpenAI de Venice, nous pouvons construire une passerelle suffisamment petite pour être compréhensible et suffisamment utile pour se placer devant de vraies applications.