Compteur de tokens IA : surveiller un modèle gratuit
Mesurez les tokens de vos appels à un modèle d’IA gratuit, posez un plafond quotidien et repérez les angles morts avant d’épuiser votre quota.

Un petit compteur de tokens avant de faire confiance à un modèle gratuit
À 23 h 40, une boucle de relance peut vider un quota gratuit sans même déclencher d'alerte visible. Une requête échoue, le client la renvoie automatiquement, et personne ne regarde le suivi de consommation. Le lendemain matin, le quota est épuisé : la démo prévue ne fonctionne plus, et le message d'erreur ne dit presque rien sur ce qui s'est passé.
Ce scénario n'est pas une raison de rajouter un outil de supervision à chaque prototype. En revanche, il pointe un problème très concret : si vous ne savez pas combien de tokens vos requêtes consomment, vous ne pouvez pas vraiment vérifier si l'offre gratuite tient encore ses promesses pour votre usage.
Une solution simple consiste à mettre un compteur entre votre application et le fournisseur du modèle. Il lit les données d'usage renvoyées par l'API, écrit une ligne de journal par appel, puis bloque les nouvelles requêtes quand le plafond quotidien est atteint. Dans cet article, je montre comment construire ce mécanisme en Python, le tester sans brûler de quota, et comprendre ses limites avant de l'utiliser en production.
Pourquoi mesurer les tokens d'un modèle gratuit ?
Les offres « gratuites » sont pratiques pour découvrir un modèle, tester une intégration ou préparer une démo. Mais « gratuit » ne veut pas dire « sans contraintes ». Le fournisseur peut fixer un plafond de tokens, limiter le débit, ou changer les conditions d'accès au fil du temps. Et côté application, une relance automatique ou une utilisation plus massive que prévu peut consommer le quota bien plus vite qu'un test manuel.
Le point important, ce n'est pas seulement d'avoir un total mensuel. Il faut pouvoir relier la consommation à des requêtes concrètes, puis décider quoi faire quand la limite approche. Sans journal exploitable, un quota épuisé ressemble à une panne intermittente : impossible de dire si l'origine vient d'un pic de trafic, d'une erreur côté client, d'un changement côté fournisseur, ou d'une relance mal configurée.
Ce que le compteur doit faire - et rien de plus
Pour un prototype simple, un compteur placé devant un endpoint de modèle peut remplir trois tâches :
- Lire les tokens d'entrée et de sortie dans la réponse du fournisseur.
- Ajouter une ligne au journal pour chaque appel traité.
- Refuser les nouvelles requêtes une fois le plafond journalier atteint.
Je garde le périmètre volontairement étroit. L'objectif n'est pas de remplacer la facturation du fournisseur, un outil de supervision, ou un système de quotas par utilisateur. L'idée est juste de produire une trace locale et une limite opérationnelle simple.
Le principe ressemble à un compteur électrique : ça aide à décider, mais ça ne garantit pas que la « facture finale » sera identique. Pour les tokens, la donnée la plus fiable est souvent le bloc usage renvoyé par l'API. Une estimation basée sur la longueur du texte peut dépanner, mais elle ne remplace pas le décompte réel du fournisseur.
Le quota gratuit est une dépendance, pas un budget garanti
Dans un projet interne, un service gratuit peut suffire. Ça devient plus risqué quand ça porte une démo client, un processus métier, ou une fonctionnalité attendue par des utilisateurs. Le coût direct est faible, mais le coût d'une indisponibilité peut être élevé : temps perdu, démo reportée, équipe mobilisée pour diagnostiquer un incident évitable.
Avant d'utiliser un modèle gratuit, vérifiez donc : le plafond annoncé, les limites de débit, et la façon dont le fournisseur expose les statistiques d'utilisation. Et gardez en tête que tout ça peut bouger. Un chiffre indiqué dans une doc (ou dans une page produit) ne devrait pas être traité comme une promesse durable sans relecture des conditions en vigueur.
Construire un compteur minimal en Python
Le code ci-dessous tient dans un fichier meter.py. Il utilise uniquement la bibliothèque standard de Python : aucun paquet externe n'est nécessaire. Le programme reçoit une requête HTTP, la transmet au fournisseur, lit la réponse, puis écrit les données d'usage dans un fichier JSONL (une ligne JSON par appel).
Voici une base inspirée du cas pratique de la publication d'origine. Avant de l'utiliser, adaptez la partie « payload » et la lecture de la réponse au format réel de votre fournisseur.
import json
import os
import time
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib import request as urlrequest
UPSTREAM = os.environ.get("UPSTREAM_URL", "")
TOKEN = os.environ.get("UPSTREAM_TOKEN", "")
DAY_CAP = int(os.environ.get("DAY_TOKEN_CAP", "200000"))
LEDGER = os.environ.get("LEDGER_PATH", "ledger.jsonl")
PORT = int(os.environ.get("PORT", "8080"))
STUB = os.environ.get("METER_STUB") == "1"
_lock = threading.Lock()
def spent_today():
today = time.strftime("%Y-%m-%d")
total = 0
try:
with open(LEDGER, encoding="utf-8") as ledger:
for line in ledger:
row = json.loads(line)
if row["day"] == today:
total += row["total_tokens"]
except FileNotFoundError:
pass
return total
def record(prompt_tokens, completion_tokens):
row = {
"day": time.strftime("%Y-%m-%d"),
"timestamp": time.time(),
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
with _lock:
with open(LEDGER, "a", encoding="utf-8") as ledger:
ledger.write(json.dumps(row) + "\\n")
return row
def call_upstream(payload):
if STUB:
text = payload["messages"][-1]["content"]
return {
"choices": [{"message": {"role": "assistant", "content": "stub:" + text}}],
"usage": {"prompt_tokens": 12, "completion_tokens": 8},
}
req = urlrequest.Request(
UPSTREAM,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}",
},
method="POST",
)
with urlrequest.urlopen(req, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
class Meter(BaseHTTPRequestHandler):
def do_POST(self):
try:
length = int(self.headers["Content-Length"])
body = json.loads(self.rfile.read(length))
except (ValueError, KeyError) as exc:
return self.send_json(400, {"error": {"type": "bad_request", "detail": str(exc)}})
spent = spent_today()
if spent >= DAY_CAP:
return self.send_json(429, {
"error": {"type": "day_cap_reached", "spent": spent, "cap": DAY_CAP}
})
data = call_upstream(body)
usage = data.get("usage") or {}
row = record(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
data["meter"] = row
self.send_json(200, data)
def send_json(self, status, obj):
raw = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", PORT), Meter).serve_forever()
Le code est volontairement compact : pas d'authentification, pas de base de données, pas de streaming. Ce sont des choix, pas des « coins oubliés ». Avant adaptation, vérifiez surtout que le format des requêtes, l'URL d'API et la structure de la réponse correspondent bien à votre fournisseur.
Ce que contient une ligne du journal
Chaque appel consigné enregistre la date, un horodatage, les tokens d'entrée, les tokens de sortie et leur total. Le JSONL est pratique pour un prototype : un fichier lisible, facile à traiter avec un script, sans serveur de base de données.
Le revers, c'est que le code relit le fichier entier pour recalculer la consommation du jour. Pour quelques appels, c'est acceptable. Quand le fichier grossit, ce recalcul devient pénible. À partir de là, un journal local simple n'est plus un bon choix pour un service partagé à trafic réel.
Lancer un test sans appeler le fournisseur
La branche de test, activée par METER_STUB=1, renvoie une réponse déterministe avec une consommation fixe. L'objectif est de vérifier le circuit sans réseau et sans consommer de quota :
METER_STUB=1 LEDGER_PATH=/tmp/ledger.jsonl PORT=8080 python3 meter.py &
curl -s localhost:8080/v1/chat \
-d '{"messages":[{"role":"user","content":"bonjour"}]}' \
| python3 -m json.tool
Le résultat doit contenir une réponse préfixée par stub:, un bloc usage, et un bloc meter avec un total de 20 tokens. Puis vérifiez le journal avec cat /tmp/ledger.jsonl. Un appel = une ligne ; deux appels = deux lignes. Cette vérification rapide permet de valider le comportement de base avant d'ajouter d'autres fonctionnalités.
Tester le compteur et ses angles morts
Un compteur qui marche sur le « chemin heureux » ne veut pas dire qu'il est fiable. Les vraies surprises viennent des réponses incomplètes, de la concurrence, ou d'un processus qui s'arrête au mauvais moment.
Vérifier que le plafond bloque réellement
Lancez le programme avec un plafond inférieur aux 20 tokens du test de base :
METER_STUB=1 DAY_TOKEN_CAP=10 LEDGER_PATH=/tmp/ledger.jsonl python3 meter.py
Le premier appel passe, car le compteur démarre à zéro. Il enregistre ensuite 20 tokens. Le deuxième appel reçoit une réponse HTTP 429 avec la consommation observée et le plafond configuré.
Ce test montre un choix important : le code vérifie le total avant l'appel, et n'empêche pas une requête unique qui dépasserait le plafond une fois les tokens réellement comptés. En clair : le compteur « ne sait » pas avant l'appel si l'API va répondre avec un volume trop élevé. Il le découvre après, et entre-temps les tokens sont consommés.
Le cas le plus trompeur : pas de bloc usage
Faites un test en supprimant temporairement le bloc usage de la réponse simulée. Avec le code actuel, l'appel renverra quand même une réponse HTTP 200, et le compteur inscrira 0 token. Le journal continuera à se remplir, mais le plafond ne se déclenchera pas : vous aurez une mesure fausse, sans signal évident côté service.
C'est le défaut le plus important du prototype : il compte « ce que l'API déclare », pas ce que vous avez envoyé, ni forcément ce que le fournisseur facturera. Si le bloc d'usage est absent, incomplet, ou renvoie des zéros inattendus, ne convertissez pas cette absence en « mesure certaine ».
Deux comportements restent raisonnables :
- Refuser la réponse et signaler une erreur de mesure.
- Ou calculer une estimation à part (par exemple avec un champ
estimated: true).
Une estimation basée sur la taille du texte peut aider à détecter une dérive, mais elle ne reproduira pas exactement la tokenisation du modèle. L'essentiel est de la rendre identifiable comme estimation, et de ne pas la mélanger aux données confirmées.
Et si vous voulez vérifier en conditions réelles : comparez régulièrement le total du journal avec les chiffres du tableau de bord du fournisseur. Un écart n'est pas forcément un drame (les périodes, les requêtes échouées, ou la façon de comptabiliser peuvent varier). Mais si ça diverge, prenez ça comme un signal à investiguer, pas comme une raison d'ignorer l'un des deux chiffres.
La concurrence peut faire dépasser le plafond
Le code fait : « lire le total », « appeler le fournisseur », « écrire le résultat ». Ces étapes ne sont pas atomiques. Avec un plafond de 25 tokens, une consommation enregistrée à 20, et deux requêtes qui arrivent quasi en même temps, les deux peuvent lire 20 et passer le contrôle. Si chacune consomme 20 tokens, vous finirez avec 40 au journal.
Le verrou actuel protège seulement l'écriture du fichier, pas la décision de passage du plafond. Et ce n'est pas théorique : le serveur utilise plusieurs threads. Pour éviter que les appels franchissent ensemble la limite, il faut coordonner la décision et l'enregistrement, ou réserver une marge avant d'appeler l'API.
Une approche « simple à expliquer » consiste à verrouiller le contrôle + l'appel + l'écriture. Ça sérialise les requêtes, mais ça peut retenir le verrou pendant une réponse réseau lente. Pour un prototype à faible trafic, ça peut passer. Pour plusieurs instances ou un service partagé, une réservation atomique dans un stockage commun est généralement plus adaptée. Le bon compromis dépend du niveau de dépassement acceptable et du coût opérationnel que vous supportez.
Déployer le proxy sans confondre prototype et production
Un compteur lancé sur un ordinateur portable s'arrête quand la machine s'éteint. Et si plusieurs outils doivent partager la même mesure, vous pouvez héberger le proxy sur un serveur et pointer vos clients vers lui. Dans tous les cas, utilisez le PORT fourni via la variable d'environnement quand l'hébergeur l'exige, plutôt que de garder un port codé en dur.
Dans l'exemple d'origine, MonkeyCode sert à la fois de fournisseur de modèles et d'option d'hébergement gratuit. La publication indique que l'accès gratuit cité par l'opérateur était de 10 millions de tokens. C'est une affirmation rapportée dans ce contexte, pas une garantie éternelle : vérifiez les conditions et le plafond actuels avant de baser une démo ou un produit dessus. La publication précise aussi que l'ensemble a été préparé pour une démarche de présentation.
Les limites à régler avant d'exposer à du public
Ce code est un exemple pédagogique, pas un proxy prêt à être exposé sur Internet :
- Pas d'authentification. N'importe qui peut potentiellement appeler votre endpoint et récupérer vos clés amont ou consommer votre quota.
- Le code répond en tamponnant la réponse complète et ne gère pas le streaming. Un client qui attend des fragments ne pourra pas utiliser ce proxy tel quel.
- La gestion des erreurs reste minimale : délai dépassé, erreur 429 du fournisseur, JSON invalide... Dans ce cas, vous devez définir clairement le comportement côté client et surtout empêcher la relance infinie. Une relance trop agressive peut au contraire accélérer l'épuisement du quota.
- Le journal JSONL ne gère ni quotas par utilisateur, ni plusieurs instances partageant un plafond, ni la rotation des fichiers. Et la « journée » dépend de l'environnement du serveur : si vous avez besoin d'une zone horaire précise, définissez-la explicitement et testez comment le fournisseur agrège ses propres statistiques.
Pour qui ce mécanisme est utile ?
Ce compteur convient bien si vous testez une API, si une petite équipe prépare une démo, ou si vous voulez juste rendre la consommation visible dans un prototype. Ça aide à comprendre ce qui se passe sur le chemin des données avant de partir sur une solution plus lourde.
En revanche, c'est moins pertinent si votre offre payante fournit déjà des rapports fiables par clé, si vous traitez des lots offline (et que l'export suffit), ou si votre passerelle n'aime pas le comportement « proxy » (selon le protocole). Dans ces cas, ajouter un intermédiaire peut créer plus de friction qu'il n'en enlève.
En bref : la vraie question n'est pas « faut-il toujours construire un compteur ? », mais « quel angle mort voulez-vous supprimer ? ». Pour un prototype isolé, un journal local peut suffire. Pour une équipe, il faudra souvent une authentification, une mesure partagée et des alertes. Pour un service client, il faut en plus gérer les erreurs, les quotas par utilisateur, la disponibilité et la sécurité.
Conclusion : mesurer avant de faire confiance
Un compteur minimal ne rend pas une offre gratuite « fiable » au sens large, et il ne remplace pas les chiffres du fournisseur. Par contre, il rend visible une partie du système qui reste souvent implicite : combien chaque requête a consommé, ce qui a été enregistré, et à quel moment le plafond devrait bloquer.
Commencez petit : testez le mode simulé, vérifiez le journal, forcez le plafond, puis supprimez le bloc usage pour voir comment le prototype se comporte quand la mesure disparaît. Ce dernier test est souvent plus instructif que d'ajouter une nouvelle fonctionnalité tout de suite. Ensuite, adaptez selon votre contexte : estimation, sérialisation, authentification ou stockage partagé.
Et avant de « faire confiance » à un quota gratuit, posez une question simple : que se passe-t-il si deux requêtes arrivent en même temps, ou si le fournisseur cesse de renvoyer ses statistiques ? Si vous n'avez pas la réponse, commencez par mesurer. Puis ajustez le mécanisme au risque réel de votre projet.