Aller au contenu principal

H-01 Service de quotas

Brique H de la plateforme Enterprise AI. Technologie : service de quotas développé + Redis (réservations atomiques par scripts Lua et TTL) + PostgreSQL (définitions, consommation validée). Version anglaise : ../../en/bricks/H-01-quota-service.md.

Rôle et responsabilités​

  • Appliquer des limites par tenant, projet, environnement, agent, utilisateur, modèle ou type d'usage, sur coût, tokens, requêtes, GPU-secondes, secondes média ou sessions.
  • Réserver le coût estimé avant exécution pour que des appels parallèles ne dépassent pas, confirmer le montant mesuré, libérer en cas d'échec.

Position et interactions​

  • Chemin d'exécution : synchrone sur le chemin critique (réserver avant exécution, confirmer après).
  • Gateway, Tool Gateway, workers média et sessions temps réel appellent reserve ; le middleware Go gatewaymw et le gestionnaire de contexte Python encapsulent le cycle.
  • Les réservations expirées sont balayées et libérées ; le franchissement de 50 %, 80 % et 100 % d'une limite déclenche une alerte (G-08) ; les seuils se règlent par EAI_FINOPS_ALERT_THRESHOLDS (pourcentages séparés par des virgules, 50,80,100 par défaut ; une valeur mal formée est une erreur, jamais ignorée).

Part open source et part développée​

  • Open source réutilisé : Redis (ou Valkey), PostgreSQL.
  • Développé dans la plateforme : Service complet : portées hiérarchiques, protocole de réservation, bascule de période, actions block/warn/degrade (routage vers un modèle moins cher).

Modèle de données​

Tables du Control Plane (schéma eai) : eai.quotas, eai.quota_reservations, eai.usage_events.

Chaque table de tenant a tenant_id uuid NOT NULL et une politique de sécurité au niveau des lignes sur current_setting('app.tenant_id', true)::uuid. ClickHouse usage_events est la réplique analytique de eai.usage_events (ReplacingMergeTree par event_id).

Contrat : contracts/v1/usage-event.schema.json (schema_version 1.0). L'usage est toujours remonté avec contracts/v1/usage-event.schema.json, l'audit avec contracts/v1/audit-event.schema.json.

Coût estimé, mesuré et facturé​

Chaque Usage Event porte trois montants dans cost (contrat Usage Event, cost.estimated, cost.measured, cost.billed, chacun une chaîne décimale avec devise ou null) :

MontantQuandSourceUsage
Coût estiméAvant exécutionMoteur de tarification (H-03) appliqué aux unités prévues (tokens du prompt comptés, tokens de sortie max, nombre d'images, secondes vidéo)Contrôle budgétaire, réservation de quota (H-01), seuils d'approbation
Coût mesuréAprès exécutionMoteur de tarification appliqué aux unités réellement consommées, telles que remontées par la réponse du fournisseur, le callback LiteLLM, le worker ou l'échantillonneur GPUShowback, confirmation des quotas, tableaux de bord
Coût facturéAprès rapprochementFactures et exports fournisseurs importés dans /v1/finops/reconciliation/importsFinance, analyse des écarts, refacturation

Règles appliquées par le moteur de tarification et le registre :

  • Pas de double comptage des tokens de raisonnement. pricing.reasoning_billing indique comment le fournisseur les facture : included_in_output (les tokens de raisonnement sont déjà inclus dans output_tokens, ils sont stockés dans units.reasoning_tokens pour analyse seulement et ne sont pas tarifés une seconde fois), separate (tarifés avec leur propre prix), not_billed, not_exposed.
  • Tokens en cache. cached_input_tokens, cache_read_tokens et cache_write_tokens sont tarifés avec leurs propres prix de la carte tarifaire ; les tokens d'entrée en cache sont retranchés des tokens d'entrée plein tarif lorsque le fournisseur les inclut dans input_tokens.
  • Prix applicable à la date de l'appel. La carte tarifaire retenue est celle dont la période de validité contient occurred_at ; pricing.pricing_version, pricing.price_card_id et pricing.effective_at sont figés dans l'événement, un changement de prix ultérieur ne réécrit donc jamais l'historique.
  • Devise et taux de change. Les montants gardent la devise du fournisseur ; la conversion vers la devise de reporting du tenant utilise eai.fx_rates à la date de l'appel (GET/PUT /v1/finops/fx-rates), et le taux utilisé est conservé pour l'audit.
  • Politique d'allocation des modèles locaux. Les modèles locaux (vLLM, Diffusers, faster-whisper, pyannote, Piper, workers vidéo GPU) n'ont pas de facture : leur coût vient d'une politique d'allocation (eai.allocation_policies) combinant heures GPU, surcoût d'infrastructure, énergie estimée, stockage et usage réel (GPU-secondes issues des échantillons DCGM, tokens servis), référencée par pricing.allocation_policy_id.
  • Champs de l'événement. Chaque événement enregistre le tarif applicable à la date de l'appel, la devise, le fournisseur, le modèle, la région, le tenant, l'agent, la session et la tâche (pricing, provider, model_id, region, tenant_id, agent_id, session_id, task_id), plus les identifiants de projet, environnement, run, requête, requête fournisseur et trace pour la corrélation.
  • LiteLLM est une source d'usage, pas la seule source comptable. Les callbacks LiteLLM couvrent les appels LLM, embeddings et certains médias via les fournisseurs. L'inférence GPU locale, les workers média, les appels d'outils, les connecteurs, les exécutions sandbox et les opérations asynchrones (jobs batch, génération vidéo) émettent leurs propres Usage Events directement vers /v1/finops/usage ou /v1/finops/usage/batch.

Endpoints d'API​

MéthodeChemin
POST/v1/quotas
POST/v1/quotas/reserve
POST/v1/quotas/{id}/commit
POST/v1/quotas/{id}/release
GET/v1/quotas/usage
GET/v1/finops/alerts

POST /v1/quotas​

Requête :

{
"name": "invoices-agt-ap-monthly",
"scope_type": "agent",
"scope_value": "agt_ap",
"period": "month",
"enforcement": "hard",
"limit_cost": "500.00",
"currency": "EUR",
"enabled": true
}

Réponse :

{
"id": "quo_4",
"status": "active"
}

POST /v1/quotas/reserve​

Requête :

{
"scope": {
"agent_id": "agt_ap"
},
"cost": "1.80",
"tokens": 14000,
"ttl_seconds": 600
}

Réponse :

{
"reservation_id": "qres_51",
"quota_id": "quo_4",
"remaining": {
"amount": "212.40",
"currency": "EUR"
},
"expires_at": "2026-10-29T10:10:00Z"
}

POST /v1/quotas/{id}/commit​

Requête :

{
"cost": "1.62",
"tokens": 13250
}

Réponse :

{
"reservation_id": "qres_51",
"status": "committed",
"released": {
"amount": "0.18",
"currency": "EUR"
}
}

POST /v1/quotas/{id}/release​

Pas de corps de requête.

Réponse :

{
"reservation_id": "qres_51",
"status": "released"
}

GET /v1/quotas/usage​

Réponse :

{
"items": [
{
"quota_id": "quo_4",
"used": "287.60",
"reserved": "1.80",
"limit": "500.00",
"currency": "EUR",
"ratio": 0.579
}
]
}

GET /v1/finops/alerts​

Réponse :

{
"items": [
{
"quota_id": "quo_4",
"threshold": 0.8,
"triggered_at": "2026-10-27T08:00:00Z"
}
]
}

Exemples SDK​

Python​

from enterprise_ai import EnterpriseAI

client = EnterpriseAI(base_url="<control-plane-url>", api_key="eai_live_xxx", tenant="acme", locale="fr")

with client.quotas.reservation(cost="1.80", scope={"agent": "agt_ap"}) as r:
result = client.gateway.video(model="video-gen-gpu", prompt="Teaser", seconds=15)
r.commit(cost="1.62") # released automatically on exception
estimate = client.finops.estimate("openai", "gpt-4o-mini", input_tokens=12000, output_tokens=2000)
client.quotas.create(name="monthly-cost", scope_type="tenant", period="month", limit_cost="500.00", currency="EUR")
client.quotas.reserve(cost="1.80", scope={"agent": "agt_ap"})
client.quotas.commit("qres_51", cost="1.62")
client.quotas.release("qres_51")
client.quotas.usage()
client.finops.alerts()

TypeScript​

import { EnterpriseAI } from "@enterprise-ai/sdk";

const client = new EnterpriseAI({ baseUrl: "<control-plane-url>", apiKey: process.env.EAI_API_KEY, tenant: "acme", locale: "fr" });
await client.quotas.create({ name: "monthly-cost", scope_type: "tenant", period: "month", limit_cost: "500.00", currency: "EUR" });
await client.quotas.reserve({ cost: "1.80", scope: { agent: "agt_ap" } });
await client.quotas.commit("qres_51", { cost: "1.62" });
await client.quotas.release("qres_51");
await client.quotas.usage();
await client.finops.alerts.list();

Java​

EnterpriseAIClient client = EnterpriseAIClient.builder()
.baseUrl("<control-plane-url>")
.apiKey(System.getenv("EAI_API_KEY"))
.tenant("acme")
.locale(Locale.FRENCH)
.build();
client.quotas().create(Map.of("name", "monthly-cost", "scope_type", "agent", "scope_value", "agt_ap", "period", "month", "limit_cost", 500.00, "currency", "EUR"));
client.quotas().reserve(Map.of("agent_id", "agt_ap", "amount", "1.80", "currency", "EUR"));
client.quotas().commit("qres_51", Map.of("cost", 1.62));
client.quotas().release("qres_51");
client.quotas().usage();
client.finops().alerts();

Go​

client, err := eai.NewClient(eai.WithBaseURL("<control-plane-url>"), eai.WithAPIKey(os.Getenv("EAI_API_KEY")), eai.WithTenant("acme"), eai.WithLocale("fr"))
if err != nil { log.Fatal(err) }
ctx := context.Background()
_, err = client.Quotas.Create(ctx, map[string]any{"name": "ap-monthly", "scope_type": "agent", "scope_value": "agt_ap", "period": "month", "limit_cost": "500.00", "currency": "EUR"})
_, err = client.Quotas.Reserve(ctx, map[string]any{"scope": map[string]any{"agent_id": "agt_ap"}, "cost": "1.80"})
_, err = client.Quotas.Commit(ctx, "qres_51", map[string]any{"cost": "1.62"})
_, err = client.Quotas.Release(ctx, "qres_51", nil)
_, err = client.Quotas.Usage(ctx, nil)
_, err = client.FinOps.Alerts(ctx, nil)

// usage package: emit a Usage Event from a local worker
emitter := usage.NewEmitter(client, usage.Options{})
defer emitter.Close(ctx)
ev, err := usage.NewEvent("acme", "gpu_local", "worker").Model("local", "llama-70b-local", "", "").Unit("gpu_seconds", 12.4).Unit("energy_wh", 1.7).Build()
if err == nil { err = emitter.Emit(ev) }
// gatewaymw: reserve, call, commit around any http.Handler
http.Handle("/llm", gatewaymw.New(gatewaymw.Config{Client: client, Action: func(r *http.Request) string { return "llm.invoke" }, Reserve: func(r *http.Request) map[string]any { return map[string]any{"cost": "0.50", "tokens": 4000} }})(llmHandler))

Configuration​

Variables d'environnement : EAI_DATABASE_URL.

Clés de valeurs Helm (deploy/helm/enterprise-ai/values.yaml) : redis.enabled.

Sécurité​

  • Seuls tenant_admin et finops créent ou modifient des quotas ; les agents ne peuvent que réserver dans leur portée.
  • Tenant déduit de l'identifiant d'accès, jamais du corps ; sécurité au niveau des lignes PostgreSQL sur chaque table de tenant.
  • Chaque mutation et décision de politique écrit un événement d'audit chaîné par hachage (G-11).
  • Les erreurs utilisent l'enveloppe standard avec message_i18n en et fr.

Observabilité​

  • eai_quota_reservations_active, refus par motif, ratio réservation / confirmation.
  • traceparent W3C propagé, X-Request-Id renvoyé, les spans portent eai.tenant_id, eai.project, eai.agent_id, eai.run_id.

Unités FinOps​

  • Les quotas consomment le coût estimé à la réservation et le coût mesuré à la confirmation ; le coût facturé ne modifie jamais une décision de quota passée.

Modes de défaillance et résilience​

  • Les compteurs vivent dans PostgreSQL (eai.quota_counters, eai.quota_reservations), système de référence ; un adaptateur Redis peut implémenter le même protocole QuotaStore. Si PostgreSQL est indisponible, aucune réservation n'est accordée et l'appel est refusé.
  • Crash du client après réservation -> l'expiration du TTL libère le montant.

Adoption autonome​

Un client de client peut n'utiliser que les quotas pour protéger sa propre gateway LLM (gatewaymw en Go).

Tests d'acceptation​

  1. 100 réservations parallèles sur un quota de 10 EUR ne dépassent jamais 10 EUR réservés.
  2. Une confirmation inférieure à la réservation libère la différence.
  3. L'erreur quota_exceeded contient message_i18n en et fr.

Réservation sur toutes les ressources mesurées​

En plus du chat, du flux, des embeddings et du rerank du Model Gateway, chaque ressource mesurée réserve son pire cas avant l'appel sortant (eai.domains.finops.reservation) : outils, appels MCP tools/call, adaptateurs spécialisés (/v1/gateway/audio/*, /v1/gateway/images/*), jobs multimodaux et jobs médias, sessions WebSocket temps réel, embeddings internes du RAG (ingestion et requête) et de la mémoire (écriture et recherche).

  • L'estimation vient des cartes de prix H03 (finops.estimate). Sans carte applicable, le tarif de référence de la ressource est utilisé (tarifs multimodaux, prix déclaré de l'outil, durée maximale de session pour le temps réel).
  • Un quota dur dépassé renvoie 429 quota_exceeded avant tout appel sortant. Une session temps réel est alors fermée avec le code 4429.
  • La réservation est confirmée avec le montant mesuré de l'Usage Event, ou libérée si l'appel échoue (erreur amont, job en échec ou annulé, déconnexion sans session.end).
  • Une réservation restée ouverte (job en file trop long) est expirée par le job quota_expiry existant.
  • La mémoire émet désormais un Usage Event embeddings (libellés feature=memory, operation=write|search).

Feuille de route d'implémentation