Skip to content

Token Budget Management — Compter, capper, facturer chaque token

TL;DR En multi-tenant facturé à l'usage, tu dois savoir exactement combien de tokens chaque tenant consomme, en input et output, par feature, par modèle. Sans ça : tu ne peux pas facturer, tu ne peux pas capper, tu te fais ruiner par un script abusif. Les leviers 2026 : (1) count avant l'appel (tiktoken pour OpenAI, anthropic count_tokens, Mistral tokenizer), (2) context window management (trim history, summarize old, sliding window), (3) max_tokens réaliste, (4) per-tenant billing meter (Stripe usage-based ou facture mensuelle), (5) hard caps anti-runaway (kill switch si dépasse N€/h), (6) dashboards par user/feature (Grafana / Langfuse). Le pattern qui paie : middleware NestJS qui intercepte chaque call LLM, calcule le coût, push vers Stripe usage records, alerte si > 80% du quota. Mission freelance type : 15-30 k€ pour une stack de billing à l'usage complète + dashboards + alerting.


🧠 Mental model

                       USER REQUEST


                  ┌─────────────────────┐
                  │  PRE-COUNT TOKENS   │
                  │  tiktoken / count_  │
                  │  tokens API         │
                  └──────────┬──────────┘


                  ┌─────────────────────┐
                  │  QUOTA CHECK        │
                  │  - tenant cap day   │
                  │  - tenant cap month │
                  │  - feature cap      │
                  │  - user cap         │
                  └──────────┬──────────┘
                             │ ok

                  ┌─────────────────────┐
                  │  CONTEXT WINDOW MGT │
                  │  - trim history     │
                  │  - summarize old    │
                  │  - max_tokens cap   │
                  └──────────┬──────────┘


                  ┌─────────────────────┐
                  │   LLM CALL          │
                  └──────────┬──────────┘


                  ┌─────────────────────┐
                  │  USAGE EXTRACTION   │
                  │  input/output/cache │
                  │  → EUR cost         │
                  └──────────┬──────────┘


                  ┌─────────────────────┐
                  │  METER & BILLING    │
                  │  Stripe usage record│
                  │  + Postgres ledger  │
                  │  + Langfuse score   │
                  └──────────┬──────────┘


                       RESPONSE + USAGE

Analogie : un LLM sans token budget c'est un Uber sans compteur. Tu prends la course, à la fin tu n'as pas de tarif, et chaque chauffeur facture comme il veut. Le token budget c'est le compteur calibré : il démarre dès le START (count avant call), accumule pendant le trajet (input/output streaming), et affiche le montant à la sortie. En plus, il refuse de partir si le client n'a pas de quota (quota check), et il alerte le central si le compteur explose (kill switch).

Deux comptages distincts :

  • Soft count (estimation pré-call) : pour vérifier le quota AVANT d'appeler. Précis à ±2%.
  • Hard count (post-call, depuis usage du provider) : la vérité pour facturer.

🛠️ Code minimal

Middleware NestJS qui count avant + meter après.

ts
// libs/billing/tokenizer.ts
import { encoding_for_model, Tiktoken } from "@dqbd/tiktoken";

const encoders: Record<string, Tiktoken> = {};

/**
 * SOFT count, local et synchrone — sert UNIQUEMENT au quota check pré-call (latence ~0).
 * Pour OpenAI : tiktoken est l'encodeur officiel, exact.
 * Pour Anthropic / Mistral : leurs tokenizers ne sont PAS portés en JS → on approxime
 *   par chars/4. C'est une ESTIMATION (±10-15% sur le FR/code), jamais une vérité de facturation.
 *
 * ⚠️ Ne JAMAIS facturer sur ce count. Le HARD count vient de `usage` du provider après l'appel.
 *    Pour un quota STRICT côté Anthropic (refus à la limite), appelle l'API exacte
 *    count_tokens (voir approxClaudeTokensExact ci-dessous) — gratuite mais async + 1 round-trip.
 */
export function approxTokens(model: string, text: string): number {
  if (model.startsWith("gpt") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o5")) {
    encoders[model] ??= encoding_for_model(model as any);
    return encoders[model].encode(text).length;
  }
  // claude-*, mistral-* : pas de tokenizer JS public → ratio. Calibre-le sur tes vrais prompts.
  return Math.ceil(text.length / 4);
}

Le piège du tokenizer Claude. tiktoken est l'encodeur d'OpenAI. Sur du texte Claude il sous-compte de ~15-20 % (bien plus sur du code ou du non-anglais) — ne l'utilise jamais pour estimer un coût Anthropic. Le tokenizer Claude n'est pas porté en SDK JS public, donc pour un soft count local tu n'as que le ratio chars/4. Pour un count exact, Anthropic expose POST /v1/messages/count_tokens (gratuit, ne consomme pas de rate limit) — le seul comptage fiable avant facturation. Le count est model-specific : passe le même model que pour l'inférence (Opus 4.8 et Opus 4.7 partagent le même tokenizer ; Sonnet 4.6 / Haiku 4.5 comptent différemment — re-baseline avec count_tokens si tu changes de modèle).

ts
// libs/billing/tokenizer-anthropic.ts
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic(); // lit ANTHROPIC_API_KEY

/** HARD count Anthropic, exact, pour les quotas stricts. Async, ~1 round-trip, gratuit. */
export async function exactClaudeTokens(model: string, messages: Anthropic.MessageParam[]): Promise<number> {
  const res = await anthropic.messages.countTokens({ model, messages });
  return res.input_tokens;
}
ts
// libs/billing/cost-calc.ts
type ModelPrice = { input: number; output: number; cacheRead?: number; cacheWrite?: number };

// ⚠️ Source de vérité = la doc provider. Ici on stocke en EUR/MTok (≈ USD * 0.92, à recaler).
// Anthropic 2026 (USD officiel) : haiku-4-5 = 1/5, sonnet-4-6 = 3/15, opus-4-8 = 5/25 (input/output, 1M ctx).
// Cache : read ≈ 0.1x input, write ≈ 1.25x input (5 min TTL) / 2x (1h TTL). Recalcule, ne devine pas.
const PRICES_EUR_PER_MTOK: Record<string, ModelPrice> = {
  "claude-haiku-4-5":   { input: 0.92, output: 4.6,  cacheRead: 0.092, cacheWrite: 1.15 },
  "claude-sonnet-4-6":  { input: 2.76, output: 13.8, cacheRead: 0.276, cacheWrite: 3.45 },
  "claude-opus-4-8":    { input: 4.6,  output: 23,   cacheRead: 0.46,  cacheWrite: 5.75 },
  "gpt-4o-mini":        { input: 0.14, output: 0.56 },
  "gpt-4o":             { input: 2.4,  output: 9.6 },
  "mistral-large-2":    { input: 1.8,  output: 5.4 },
  "mistral-small":      { input: 0.18, output: 0.54 },
};

export function costEur(args: {
  model: string;
  inputTokens: number;
  outputTokens: number;
  cacheReadTokens?: number;
  cacheWriteTokens?: number;
}): number {
  const p = PRICES_EUR_PER_MTOK[args.model] ?? { input: 0, output: 0 };
  const M = 1_000_000;
  return (
    (args.inputTokens * p.input) / M +
    (args.outputTokens * p.output) / M +
    ((args.cacheReadTokens ?? 0) * (p.cacheRead ?? 0)) / M +
    ((args.cacheWriteTokens ?? 0) * (p.cacheWrite ?? 0)) / M
  );
}
ts
// libs/billing/meter.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "@app/db";
import Stripe from "stripe";

@Injectable()
export class MeterService {
  private stripe = new Stripe(process.env.STRIPE_SECRET!);

  constructor(private prisma: PrismaService) {}

  async record(opts: {
    tenantId: string;
    userId: string;
    feature: string;
    model: string;
    inputTokens: number;
    outputTokens: number;
    cacheReadTokens?: number;
    cacheWriteTokens?: number;
    eurCost: number;
    traceId?: string;
  }) {
    // 1. Local ledger (Postgres, immutable)
    await this.prisma.usageEvent.create({ data: { ...opts, ts: new Date() } });

    // 2. Push Stripe usage record (if tenant on usage-based plan)
    const sub = await this.prisma.tenant.findUnique({
      where: { id: opts.tenantId },
      select: { stripeSubscriptionItemId: true, billingMode: true },
    });
    if (sub?.billingMode === "usage_based" && sub.stripeSubscriptionItemId) {
      const quantity = Math.ceil(opts.eurCost * 100); // facturer en centimes
      await this.stripe.subscriptionItems.createUsageRecord(sub.stripeSubscriptionItemId, {
        quantity,
        timestamp: Math.floor(Date.now() / 1000),
        action: "increment",
      });
    }

    // 3. Check threshold alerts (80%, 100%)
    await this.checkAlerts(opts.tenantId, opts.eurCost);
  }

  private async checkAlerts(tenantId: string, addedEur: number) {
    // ... query monthly cumulative, fire webhook if crossed 80% / 100% ...
  }
}
ts
// libs/llm/billed-call.ts
import { router } from "./router-client";
import { MeterService } from "@app/billing";

export async function billedCall(deps: { meter: MeterService }, opts: {
  tenantId: string; userId: string; feature: string;
  model: string; messages: any[]; max_tokens?: number;
}) {
  // 1. Pre-count (soft)
  const inputApprox = opts.messages.reduce((a, m) => a + approxTokens(opts.model, JSON.stringify(m)), 0);
  const expectedCost = costEur({ model: opts.model, inputTokens: inputApprox, outputTokens: opts.max_tokens ?? 600 });

  // 2. Quota check (omitted: see cap.service.ts)
  // assertQuota(opts.tenantId, expectedCost);

  // 3. Call
  const res = await router.chat.completions.create({
    model: opts.model,
    messages: opts.messages,
    max_tokens: opts.max_tokens ?? 600,
    user: `${opts.tenantId}:${opts.userId}`,
  } as any);

  // 4. Hard count (truth)
  const inT = res.usage?.prompt_tokens ?? 0;
  const outT = res.usage?.completion_tokens ?? 0;
  const cacheR = (res.usage as any)?.cache_read_input_tokens ?? 0;
  const cacheW = (res.usage as any)?.cache_creation_input_tokens ?? 0;
  const cost = costEur({ model: opts.model, inputTokens: inT, outputTokens: outT, cacheReadTokens: cacheR, cacheWriteTokens: cacheW });

  // 5. Meter
  await deps.meter.record({
    tenantId: opts.tenantId,
    userId: opts.userId,
    feature: opts.feature,
    model: opts.model,
    inputTokens: inT, outputTokens: outT,
    cacheReadTokens: cacheR, cacheWriteTokens: cacheW,
    eurCost: cost,
  });

  return { ...res, eurCost: cost };
}

🎬 Cas d'usage concrets

Cas 1 — SaaS multi-tenant facturé au token (chiffre exact à facturer)

Le client : éditeur SaaS B2B FR (CRM enrichi à l'IA), facture mensuelle par tenant calculée sur l'usage LLM (~1.4x le coût provider, marge 40%).

Problème initial : facturation manuelle Excel-driven, erreurs ±15%, certains tenants surfacturés (réclament), d'autres sous-facturés (perte de marge ~120 k€/an estimée).

Solution :

  1. Table usage_event append-only Postgres : 1 ligne par appel LLM (tenant_id, feature, model, input_tokens, output_tokens, cache_*, eur_cost, ts).
  2. Job mensuel d'agrégation → fichier Stripe usage records → invoice auto.
  3. Dashboard tenant en SSO : voit son usage en temps réel, peut télécharger CSV detailled.
  4. Alerte à 80% du quota inclus dans son plan + upgrade in-app.

Mission : 25 k€, 5 semaines. ROI client : récupération 120 k€/an + 0 réclamation facturation.

Cas 2 — Freemium e-commerce 10k tokens/mois gratuit

Le client : DTC FR avec module chatbot embarqué. Plan freemium : 10 000 tokens/mois inclus, au-delà → "passez au plan Pro 29€/mois".

Mécanisme :

  1. Quota Redis incrémenté à chaque call : INCRBY usage:tenant:202605 <tokens>.
  2. Avant chaque call, check si quota >= cap mensuel → si oui, retour 402 (HTTP "Payment Required") + UI upgrade.
  3. Si quota >= 80%, banner "Plus que 2k tokens, upgrade ?".
  4. Reset au 1er du mois (cron).
  5. Webhook Stripe → mise à jour cap si upgrade.

Mission : 14 k€, 3 semaines.

Cas 3 — Anti-abuse pour script kiddies

Le client : néobanque FR avec chatbot. Quelqu'un a découvert une faille (token JWT mal scopé) et fait tourner un script qui spam le chat 1 req/sec depuis 3 IPs différentes. En 4h : 50k requêtes → ~600€ d'API claqués.

Solution post-incident :

  1. Hard cap per-user : 200 requêtes / heure (au-delà → 429).
  2. Hard cap per-IP : 50 requêtes / heure (anonyme).
  3. Anomaly detection : si un user dépasse +10x sa moyenne 30j → suspend + alerte.
  4. Cost circuit breaker global : si total/heure dépasse 50€ → kill switch (toutes les nouvelles requêtes 503 + alerting).
  5. Audit log : qui, quand, depuis quelle IP, quel feature.

Mission post-incident : 11 k€, 2 semaines. Couvert par leur cyber-insurance.


🛠️ Exemple end-to-end — Middleware NestJS qui track tokens + facture Stripe usage-based + alerte 80%

Contexte : éditeur SaaS LegalTech FR (suite des cas précédents). Mix de plans :

  • Plan Free : 5k tokens/mois, hard cap.
  • Plan Pro : 100k tokens/mois inclus, overage 0.30€ / 1k tokens.
  • Plan Enterprise : usage-based pur, facturé à la fin du mois.

1) Schéma Postgres

sql
CREATE TABLE usage_event (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  ts            TIMESTAMPTZ NOT NULL DEFAULT now(),
  tenant_id     UUID NOT NULL,
  user_id       TEXT NOT NULL,
  feature       TEXT NOT NULL,
  model         TEXT NOT NULL,
  input_tokens  INT NOT NULL,
  output_tokens INT NOT NULL,
  cache_read_tokens  INT DEFAULT 0,
  cache_write_tokens INT DEFAULT 0,
  eur_cost      NUMERIC(12, 6) NOT NULL,
  trace_id      TEXT,
  request_id    TEXT
);

CREATE INDEX idx_usage_tenant_month ON usage_event (tenant_id, date_trunc('month', ts));
CREATE INDEX idx_usage_user_day ON usage_event (user_id, date_trunc('day', ts));

CREATE TABLE tenant_plan (
  tenant_id     UUID PRIMARY KEY,
  plan          TEXT NOT NULL CHECK (plan IN ('free','pro','enterprise')),
  tokens_included_per_month INT NOT NULL DEFAULT 0,
  hard_cap_eur_month NUMERIC(12, 2),
  stripe_sub_item_id TEXT,
  alert_threshold_pct INT[] DEFAULT ARRAY[80,100]
);

2) Quota Cap Service

ts
// libs/billing/cap.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "@app/db";
import { Redis } from "ioredis";

@Injectable()
export class CapService {
  constructor(private prisma: PrismaService, private redis: Redis) {}

  async assert(opts: { tenantId: string; userId: string; expectedEurCost: number; expectedTokens: number }) {
    const plan = await this.prisma.tenantPlan.findUnique({ where: { tenantId: opts.tenantId } });
    if (!plan) throw new Error("No plan");

    // 1. monthly tokens included
    const monthKey = `tokens:${opts.tenantId}:${currentMonthKey()}`;
    const used = parseInt((await this.redis.get(monthKey)) ?? "0", 10);
    if (plan.plan === "free" && used + opts.expectedTokens > plan.tokens_included_per_month) {
      throw new Forbidden("monthly_quota_exhausted");
    }

    // 2. hard cap EUR (anti-runaway)
    if (plan.hard_cap_eur_month) {
      const eurKey = `eur:${opts.tenantId}:${currentMonthKey()}`;
      const usedEur = parseFloat((await this.redis.get(eurKey)) ?? "0");
      if (usedEur + opts.expectedEurCost > plan.hard_cap_eur_month) {
        throw new Forbidden("monthly_cost_cap");
      }
    }

    // 3. per-user rate limit (anti-abuse)
    const userKey = `user:${opts.userId}:hour:${new Date().toISOString().slice(0, 13)}`;
    const userCount = await this.redis.incr(userKey);
    await this.redis.expire(userKey, 3600);
    if (userCount > 200) throw new Forbidden("user_hourly_cap");
  }

  async record(opts: { tenantId: string; tokens: number; eur: number }) {
    const monthKey = currentMonthKey();
    await this.redis.incrby(`tokens:${opts.tenantId}:${monthKey}`, opts.tokens);
    await this.redis.incrbyfloat(`eur:${opts.tenantId}:${monthKey}`, opts.eur);
    await this.redis.expire(`tokens:${opts.tenantId}:${monthKey}`, 86400 * 40);
    await this.redis.expire(`eur:${opts.tenantId}:${monthKey}`, 86400 * 40);
  }
}

function currentMonthKey() { return new Date().toISOString().slice(0, 7); }
class Forbidden extends Error { constructor(public reason: string) { super(reason); } }

3) Middleware NestJS qui orchestre

ts
// libs/llm/billed-llm.service.ts
import { Injectable } from "@nestjs/common";
import { CapService } from "@app/billing";
import { MeterService } from "@app/billing";
import { router } from "./router-client";
import { approxTokens, costEur } from "@app/billing";

@Injectable()
export class BilledLlmService {
  constructor(private cap: CapService, private meter: MeterService) {}

  async call(opts: {
    tenantId: string; userId: string; feature: string;
    model: string; messages: any[]; max_tokens?: number;
  }) {
    // pre-count
    const inputApprox = opts.messages.reduce((a, m) => a + approxTokens(opts.model, JSON.stringify(m)), 0);
    const expectedCost = costEur({
      model: opts.model,
      inputTokens: inputApprox,
      outputTokens: opts.max_tokens ?? 600,
    });

    await this.cap.assert({
      tenantId: opts.tenantId,
      userId: opts.userId,
      expectedTokens: inputApprox + (opts.max_tokens ?? 600),
      expectedEurCost: expectedCost,
    });

    const res = await router.chat.completions.create({
      model: opts.model,
      messages: opts.messages,
      max_tokens: opts.max_tokens ?? 600,
      user: `${opts.tenantId}:${opts.userId}`,
    } as any);

    const inT = res.usage?.prompt_tokens ?? 0;
    const outT = res.usage?.completion_tokens ?? 0;
    const cacheR = (res.usage as any)?.cache_read_input_tokens ?? 0;
    const cacheW = (res.usage as any)?.cache_creation_input_tokens ?? 0;
    const realCost = costEur({
      model: opts.model,
      inputTokens: inT,
      outputTokens: outT,
      cacheReadTokens: cacheR,
      cacheWriteTokens: cacheW,
    });

    await this.cap.record({ tenantId: opts.tenantId, tokens: inT + outT, eur: realCost });
    await this.meter.record({
      tenantId: opts.tenantId,
      userId: opts.userId,
      feature: opts.feature,
      model: opts.model,
      inputTokens: inT,
      outputTokens: outT,
      cacheReadTokens: cacheR,
      cacheWriteTokens: cacheW,
      eurCost: realCost,
    });

    return { ...res, eurCost: realCost };
  }
}

4) Context window management

ts
// libs/llm/context-window.ts
import { approxTokens } from "@app/billing";

export function trimHistory(args: {
  model: string;
  messages: { role: string; content: string }[];
  maxInputTokens: number;
}): typeof args.messages {
  let tokens = args.messages.reduce((a, m) => a + approxTokens(args.model, m.content), 0);
  const msgs = [...args.messages];
  while (tokens > args.maxInputTokens && msgs.length > 2) {
    // drop oldest user/assistant pair, keep system + last 2 turns
    const dropped = msgs.splice(1, 2);
    tokens -= dropped.reduce((a, m) => a + approxTokens(args.model, m.content), 0);
  }
  return msgs;
}

export async function summarizeOldHistory(args: {
  model: string;
  messages: { role: string; content: string }[];
  keepLast: number;
}) {
  if (args.messages.length <= args.keepLast + 1) return args.messages;
  const old = args.messages.slice(1, -args.keepLast);
  const summary = await summarize(old); // small LLM call
  return [
    args.messages[0],
    { role: "user", content: `Résumé conversation précédente : ${summary}` },
    ...args.messages.slice(-args.keepLast),
  ];
}

5) Alerts 80% / 100%

ts
// libs/billing/alerts.service.ts
@Injectable()
export class AlertsService {
  async checkMonthly(tenantId: string) {
    const plan = await this.prisma.tenantPlan.findUnique({ where: { tenantId } });
    if (!plan) return;
    const eurUsed = parseFloat((await this.redis.get(`eur:${tenantId}:${currentMonthKey()}`)) ?? "0");
    const cap = plan.hard_cap_eur_month;
    if (!cap) return;
    const pct = (eurUsed / cap) * 100;
    for (const t of plan.alert_threshold_pct) {
      const alertKey = `alert:${tenantId}:${currentMonthKey()}:${t}`;
      const sent = await this.redis.get(alertKey);
      if (pct >= t && !sent) {
        await this.email.send({
          to: plan.billingEmail,
          subject: `[Acme] Vous avez consommé ${t}% de votre budget mensuel`,
          template: t === 100 ? "cap_reached" : "cap_warning",
          data: { eurUsed, cap, pct },
        });
        await this.redis.set(alertKey, "1", "EX", 86400 * 35);
      }
    }
  }
}

6) Dashboard tenant SSO

Endpoint API /me/usage qui renvoie :

json
{
  "month": "2026-05",
  "plan": "pro",
  "tokensIncluded": 100000,
  "tokensUsed": 67320,
  "tokensRemaining": 32680,
  "eurUsedThisMonth": 8.42,
  "byFeature": [
    { "feature": "doc_summarize", "tokens": 48000, "eur": 6.1 },
    { "feature": "agent_qa", "tokens": 19320, "eur": 2.32 }
  ],
  "byUser": [
    { "userId": "u_001", "tokens": 31000 },
    { "userId": "u_002", "tokens": 24320 }
  ],
  "byDay": [/* ... */]
}

Le front affiche un panneau billing avec barre de progression, sparkline, CTA upgrade.

7) Job d'invoice mensuel

Cron 1er du mois 3h UTC :

ts
// jobs/monthly-billing.ts
@Cron("0 3 1 * *")
async function monthlyBilling() {
  const tenants = await prisma.tenantPlan.findMany({ where: { plan: "enterprise" } });
  for (const t of tenants) {
    const events = await prisma.usageEvent.aggregate({
      where: {
        tenantId: t.tenantId,
        ts: { gte: lastMonthStart(), lt: thisMonthStart() },
      },
      _sum: { eurCost: true, inputTokens: true, outputTokens: true },
    });
    const total = events._sum.eurCost ?? 0;
    const margin = total * 1.4;
    await stripe.invoiceItems.create({
      customer: t.stripeCustomerId,
      currency: "eur",
      unit_amount: Math.round(margin * 100),
      quantity: 1,
      description: `LLM usage ${lastMonthLabel()}`,
    });
    await stripe.invoices.create({ customer: t.stripeCustomerId, auto_advance: true });
  }
}

Mission complète : 28 k€ (5-6 sem) avec docs, dashboards, intégration Stripe, tests E2E.


🎯 Patterns courants

  • Pre-count + post-count : pre (soft, local) pour le quota, post (hard, depuis usage) pour facturer. Ne jamais facturer sur le soft count.
  • Approximate tokenizer pour Anthropic/Mistral : leurs tokenizers ne sont pas portés en SDK JS public — ratio chars/4 comme soft count UNIQUEMENT. tiktoken n'est PAS valide pour Claude (sous-compte de ~15-20 %).
  • count_tokens API Anthropic (POST /v1/messages/count_tokens, ou client.messages.countTokens(...)) : gratuit, exact, ne consomme pas de rate limit. Le seul comptage fiable avant facturation — à utiliser pour tout quota strict. Model-specific : passe le même model que l'inférence.
  • tiktoken : pour OpenAI uniquement, encoder local rapide et exact.
  • max_tokens réaliste : si la réponse fait typiquement 200 tokens, mets 300 (pas 4096). Sinon, tu réserves du budget pour rien et tu paies parfois plus.
  • Cap multi-niveaux : tenant mensuel (€), tenant journalier (€), user horaire (req), IP horaire (req). Defense in depth.
  • Stripe metered billing : usage records par tenant, agrégation auto à la fin du mois.
  • Ledger immutable : un usage_event par appel, append-only. Pas d'UPDATE. Audit RGPD friendly.
  • Per-feature breakdown : tag feature (agent_qa, summarize, compliance_check) pour comprendre quelle feature coûte quoi.
  • Context window management : trim history, summarize old, sliding window. Évite l'inflation des tokens input sur chat long.
  • Alert webhook + email : 80% prévient, 100% bloque (ou upgrade payant).
  • Anomaly detection : moyenne mobile 30j + standard deviation → flag si > 3 sigma.
  • Dashboard tenant exposé : transparence = confiance client.

🔄 Versions & écosystème 2026

  • tiktoken : tokenizer officiel OpenAI, port JS @dqbd/tiktoken. Précis.
  • Anthropic count_tokens API : POST /v1/messages/count_tokens. Exact, gratuit.
  • mistral-common tokenizer : SDK Python officiel.
  • LiteLLM : litellm.token_counter() unifié multi-modèle.
  • Stripe Billing usage-based : subscription_item.usage_record v1, "Metered" pricing model.
  • Lago (open-source) : alternative à Stripe metered, hébergeable, FR-friendly.
  • Orb (Sequoia) : commercial pour usage-based metering avancé.
  • Langfuse cost tracking : automatique sur les usage standards, mais override possible pour pricing custom.

⚠️ Pitfalls

  1. Compter en chars / 4 sans calibrer : sur le FR, parfois c'est 5 chars / token, parfois 3 (selon ponctuation). Vérifier sur 1000 calls réels.
  2. Oublier les cache tokens : chez Anthropic, cache_read coûte ~0.1x l'input (10x moins) et cache_write coûte ~1.25x (TTL 5 min) ou ~2x (TTL 1h). Si tu comptes les cache_read au prix input plein, tu surfactures un tenant qui bénéficie du cache → réclamation. Compte cacheReadTokens et cacheWriteTokens séparément, chacun à son tarif. Et surveille usage.cache_read_input_tokens : s'il est à 0 alors que tu t'attends à du cache, un invalidateur silencieux (timestamp dans le system prompt, JSON non trié, set d'outils variable) casse le préfixe — tu paies le plein tarif sans le savoir.
  3. max_tokens à 4096 partout : tu ne paies que ce qui est généré, MAIS ça limite parallélisme et risque OOM côté provider. Cap réaliste.
  4. Quota basé sur estimate seulement : tu autorises un call avec estimate 300, réel 2500 → quota dépassé. Toujours record sur le réel.
  5. Stripe usage records perdus : si la requête Stripe échoue, ton revenu disparaît. Mets un retry + outbox pattern.
  6. Ledger mutable : DPO veut audit immuable, ton dev UPDATE pour "fix" → tu perds la conformité. Append-only.
  7. Alertes spammées : si tu n'as pas un sent_at flag, le user reçoit 200 emails. Dedup par mois.
  8. Pas de per-user cap : un employé d'un tenant fait 100k req via copy-paste-loop → quota tenant exposé.
  9. Context window non géré : chat qui dépasse 200k tokens input → erreur ou coût explosif. Trim ou summarize.
  10. Migration plans floue : un tenant change de Pro à Free, son usage du mois n'est pas pro-rated → conflit facture.

💰 Pricing / ROI client

Mission types :

  • Audit billing actuel + plan : 6-8 k€ (1 sem).
  • Stack billing + caps + dashboards (4-6 sem) : 18-30 k€.
  • Anti-abuse / anomaly detection (2-3 sem) : 10-18 k€.
  • Maintenance billing : 1500-1800€/j 1-2j/mois.

ROI client :

  • Récupération des pertes facturation (typique 10-20% du CA LLM) → mission payée en 1-3 mois.
  • Anti-abuse évite un incident à 5-50 k€ par épisode.
  • Dashboard tenant améliore retention (transparence = trust).

🧪 Testing / Eval

  • Reconciliation test : sum(usage_event.eur) du mois ≈ facture provider ±5%. Si écart, investigate.
  • Estimate vs real : ratio estimate / real moyen, par modèle. Si écart > 15%, ajuste tokenizer.
  • Cap enforcement test : simule tenant qui spam → vérifier 429 / 402 attendu.
  • Stripe webhook E2E : créer usage_record, vérifier qu'il apparaît dans la prochaine facture.
  • Restore disaster : si Redis perd les compteurs (crash), reload depuis Postgres en < 5 min.

🔁 Quand utiliser / éviter

Utiliser :

  • SaaS multi-tenant.
  • Plans freemium / metered.
  • Boîtes avec finance / compta exigeant.
  • Domaine régulé (audit usage requis).

Use minimal :

  • POC ou démo.
  • Internal tool sans facturation.
  • B2B avec contrat à forfait pur (le client paie un fixe).

🧭 Comment un staff raisonne sur le token budget

Le débutant écrit un middleware qui log input_tokens + output_tokens. Le staff se pose d'abord quatre questions de design qui déterminent toute l'architecture :

1. Quelle est l'unité de facturation, et qui la fixe ? Token brut ? Coût EUR ? Crédit interne abstrait ? Le piège : facturer en tokens expose ton tenant à la volatilité du provider (un changement de tokenizer côté Anthropic — par ex. Opus 4.6 → 4.7 a changé le comptage, ~1×–1.35× sur le même texte — décale ta facture sans que ton client ait rien fait). Le staff facture en unité stable et abstraite (un "crédit" = N centimes de coût provider × marge), recalcule le mapping crédit↔token à chaque changement de modèle, et gèle le prix du crédit dans le contrat. Tu absorbes la volatilité tokenizer dans ta marge, pas dans la facture du client.

2. Soft count ou hard count pour le gate ? Le quota check pré-call doit être rapide (pas de round-trip réseau dans le chemin critique) → soft count local. Mais le soft count est faux de ±10-15 %. Conséquence : un tenant à 99 % de quota peut passer un call estimé à 300 tokens qui en consomme 2500 → dépassement. Deux écoles :

  • Optimiste (la plupart des SaaS) : gate sur le soft count, record sur le hard count, accepte un léger dépassement (≤ 1 call). Simple, suffisant si le cap est "soft" (overage facturé).
  • Strict (cap dur, anti-abuse, plan free) : pour les 1-2 % de calls près de la limite, appelle count_tokens (exact, ~50 ms) avant de gater. Tu paies la latence seulement au bord de la limite.

3. Où vit la vérité — Redis ou Postgres ? Redis = compteur chaud (incr atomique, gate sub-milliseconde) mais volatile. Postgres = ledger immuable, source de vérité pour la facture, mais lent pour un gate par call. Le pattern : dual-write — Redis pour le gate temps réel, Postgres usage_event append-only pour la facture et l'audit. Redis peut crasher : tu dois pouvoir reconstruire les compteurs depuis Postgres (SUM(eur_cost) GROUP BY tenant, month) en < 5 min. Si tu ne peux pas, ton anti-runaway est une illusion.

4. Qu'est-ce qui se passe quand le metering échoue ? Le call LLM a réussi, le tenant a consommé, mais ton INSERT usage_event ou ton createUsageRecord Stripe a échoué (réseau, Stripe down). Si tu jettes l'événement → revenu perdu silencieusement. Le staff applique un outbox pattern : l'usage_event Postgres est écrit dans la même transaction que la réponse (ou immédiatement après, jamais best-effort), puis un worker asynchrone (BullMQ) pousse vers Stripe avec retry idempotent. Postgres est la vérité ; Stripe est une projection qu'on peut rejouer.

Tradeoffs — où mettre le compteur

ApprocheLatence gateCohérenceRésilience crashQuand
Redis only< 1 mséventuelle❌ perd toutJamais en prod facturée
Postgres only (count par call)5-20 msforteFaible volume, cap pas critique
Redis gate + Postgres ledger< 1 ms gate, async ledgerforte (ledger)✅ (reload Redis ← PG)Défaut prod
count_tokens exact au bord+50 ms près du capexacte au tokenPlan free / cap dur / anti-abuse

Tradeoffs — soft cap vs hard cap

Soft cap (overage facturé)Hard cap (block)
UXjamais bloqué, paie le dépassement402/429 à la limite
Risque businessfacture surprise → churnfeature coupée → ticket support
Anti-runaway❌ (un script abusif facture à l'infini)
Implémentationrecord + alertgate strict + circuit breaker
Bon pourEnterprise de confianceFree / self-serve / anti-abuse

Le staff combine : soft cap sur le quota inclus (overage facturé jusqu'à X) + hard cap absolu (kill switch à Y €/h global) pour borner le pire cas.


🔥 Failure modes (les pannes qui coûtent cher)

  1. Tokenizer drift silencieux. Tu estimes Claude avec tiktoken → sous-compte 15-20 % → ton ratio estimate/real dérive, ton quota free laisse passer 20 % de trop, ta marge fond. Symptôme : reconciliation mensuelle qui s'écarte de la facture provider. Fix : count_tokens exact, ou ratio calibré + monitoré.
  2. Cache mal compté. Tu factures les cache_read au prix input plein → un tenant qui exploite bien le prompt caching est surfacturé 10x sur la partie cachée → réclamation légitime. Fix : tarif cache séparé.
  3. Estimate-only quota. Tu gates ET records sur l'estimate (jamais le réel) → ta compta diverge progressivement de la réalité provider. Fix : record TOUJOURS sur usage.
  4. Redis perd les compteurs. Crash Redis sans reload depuis Postgres → tous les quotas repartent à zéro → un tenant en dépassement repasse sous la limite → runaway. Fix : reload script testé (disaster drill).
  5. Stripe usage record perdu. Requête Stripe échoue, pas de retry → revenu disparu. Fix : outbox + retry idempotent (clé d'idempotence = usage_event.id).
  6. Alertes spammées. Pas de flag sent → 200 emails à 80 %. Fix : dédup alert:{tenant}:{month}:{threshold}.
  7. max_tokens sur-dimensionné. max_tokens: 4096 partout → tu réserves du budget de sortie inutile. Tu ne paies que le généré, MAIS : (a) ça gonfle ton expectedCost pré-call et déclenche des faux refus de quota, (b) ça augmente le risque de réponses qui partent en roue libre. Fix : cap réaliste par feature.
  8. Pas de per-user cap dans un tenant. Le quota tenant est exposé : un employé en copy-paste-loop le crame pour tout le monde. Fix : cap multi-niveaux (tenant/mois, user/heure, IP/heure).

🏋️ Exercices

Stack imposée : NestJS + Prisma/Postgres + Redis + Stripe. Provider LLM : Anthropic (Opus 4.8 / Sonnet 4.6 / Haiku 4.5). Tu peux mocker l'API mais le usage doit avoir la forme réelle (input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens).

Exercice 1 — Le costEur qui ne ment pas (échauffement)

Objectif : implémenter costEur qui gère input + output + cache_read + cache_write avec les vrais tarifs Anthropic 2026, et un test qui prouve qu'un tenant 100 % cache-read paie ~10x moins qu'un tenant 0 % cache. Indice/Solution : tarifs en USD/MTok — opus-4-8 5/25, sonnet-4-6 3/15, haiku-4-5 1/5 ; cache_read ≈ 0.1x input, cache_write ≈ 1.25x input (TTL 5 min). Stocke en table, divise par 1e6. Test : même prompt de 100k tokens, un appel cache_creation puis un appel cache_read → assert que le 2e coûte ~10 % du 1er sur la partie input.

Exercice 2 — Soft count vs hard count (le piège du quota)

Objectif : construis un middleware qui gate sur le soft count (chars/4) puis record sur le hard count, et prouve par un test que ton soft count se trompe de > 10 % sur un prompt FR riche en ponctuation/code. Indice/Solution : prends 50 prompts réels, compare approxTokens à count_tokens exact. Calcule le ratio moyen et l'écart-type. Puis : implémente le mode "strict au bord" — si used + estimate > 0.95 * cap, bascule sur count_tokens exact avant de gater. Mesure la latence ajoutée (elle ne doit toucher que ~2 % des calls).

Exercice 3 — Casse-le puis répare-le : crash Redis

Objectif : ton CapService stocke les compteurs mensuels dans Redis. Simule un FLUSHALL en plein mois. Montre que les quotas repartent à zéro (runaway), puis écris le reload depuis Postgres qui restaure l'état exact en < 5 min pour 10k tenants. Indice/Solution : le reload = SELECT tenant_id, SUM(eur_cost), SUM(input_tokens+output_tokens) FROM usage_event WHERE date_trunc('month', ts) = current → SET Redis. Batch les writes (pipeline Redis). Test : seed 10k tenants × 1000 events, FLUSHALL, reload, assert que eur:{tenant}:{month} == SUM Postgres. Bonus : pendant le reload, gate en fail-closed (refuse les calls) plutôt qu'en fail-open (laisse tout passer).

Exercice 4 — Outbox idempotent vers Stripe (production-grade)

Objectif : rends le push Stripe at-least-once mais facturé exactly-once. Stripe répond 500 une fois sur dix ; ton worker retry. Prouve qu'un tenant n'est jamais double-facturé même avec retries. Indice/Solution : écris l'usage_event en Postgres (vérité), un worker BullMQ pousse vers Stripe avec une clé d'idempotence = usage_event.id (Stripe dédup côté serveur). Marque pushed_at après succès. Test : injecte un échec Stripe au 1er essai, vérifie qu'après retry il y a exactement 1 usage record côté Stripe (mock) et que pushed_at est set. Casse-le : que se passe-t-il si le worker crash entre le succès Stripe et le UPDATE pushed_at ? (→ le retry suivant re-pousse, mais l'idempotency key empêche le double-billing).

Exercice 5 — Le cost circuit breaker global (défends le chiffre)

Objectif : implémente un kill switch : si le coût total/heure (tous tenants) dépasse KILL_EUR_PER_HOUR, toute nouvelle requête renvoie 503 + alerte oncall, jusqu'à reset manuel ou décroissance sous le seuil. Puis défends ton seuil : justifie la valeur de KILL_EUR_PER_HOUR par un calcul, pas au pif. Indice/Solution : compteur Redis glissant eur:global:{hour} (TTL 2h), check avant chaque call. Le seuil : prends le P99 du coût horaire historique × 3 (marge pour les pics légitimes), borné par "le montant max qu'on accepte de perdre sur un incident avant détection humaine" (cf. l'incident néobanque : 600 € en 4h → un seuil à 150 €/h aurait coupé après ~1h). Documente : faux positifs (un batch légitime nocturne) vs vrais positifs (script kiddie). Bonus : seuil par tenant en plus du global, pour ne pas couper tout le monde à cause d'un seul abuseur.

Exercice 6 — Reconciliation automatique (le test qui sauve la marge)

Objectif : job mensuel qui compare SUM(usage_event.eur_cost) à la facture réelle Anthropic (mock le usage agrégé du provider) et alerte si l'écart dépasse ±3 %. Trouve volontairement une source de drift et corrige-la. Indice/Solution : sources de drift classiques — (a) calls non-loggés (timeout côté app après réponse provider → tu paies mais ne records pas), (b) cache tokens oubliés, (c) ratio tokenizer faux si tu records sur l'estimate. Le fix pour (a) : record dans un finally/outbox même si la réponse au client échoue. Le test : injecte 5 % de calls "fantômes" (provider facture, app ne record pas), vérifie que la reconciliation les détecte.


🎤 En entretien

Q : Comment tu factures un tenant à l'usage sans te faire piéger par un changement de tokenizer côté provider ? R : Je facture en unité abstraite (crédit) au prix gelé dans le contrat, je recalcule le mapping crédit↔token à chaque changement de modèle, et j'absorbe la volatilité du tokenizer dans ma marge — le client ne voit jamais sa facture bouger parce qu'Anthropic a changé son comptage.

Q : Tu count avant ou après l'appel LLM, et avec quoi ? R : Les deux. Soft count local (chars/4 ou tiktoken pour OpenAI) avant l'appel pour gater le quota vite ; hard count depuis le champ usage du provider après l'appel pour facturer — c'est la seule vérité. Pour un cap dur, j'appelle count_tokens exact (gratuit chez Anthropic) sur les calls près de la limite. Et je n'utilise jamais tiktoken pour estimer du Claude : il sous-compte de 15-20 %.

Q : Ton service de metering Stripe tombe en plein call facturé. Que se passe-t-il ? R : Rien n'est perdu. L'usage_event Postgres est la source de vérité (écrit avant/avec la réponse) ; Stripe est une projection qu'un worker pousse en at-least-once avec une clé d'idempotence = l'id de l'événement, donc retry sans double-billing. Stripe down = je rejoue plus tard depuis Postgres.

Q : Comment tu empêches un script abusif de ruiner ton mois d'API ? R : Defense in depth — caps multi-niveaux (tenant/mois en €, user/heure et IP/heure en requêtes), anomaly detection (flag à > 3σ de la moyenne 30j), et un cost circuit breaker global (kill switch à un seuil = P99 horaire × 3, borné par la perte max acceptable avant détection humaine). Le hard cap absolu borne le pire cas même si tout le reste rate.


🔗 Liens

Bibliothèque tech perso — Achref