H-02 Registre des coûts
Brique H de la plateforme Enterprise AI. Technologie : PostgreSQL (eai.usage_events, source de vérité, RLS) + ClickHouse usage_events (analytique). Version anglaise : ../../en/bricks/H-02-cost-ledger.md.
Rôle et responsabilités
- Stocker chaque Usage Event une seule fois (idempotent sur event_id) avec coût estimé, mesuré et facturé.
- Rapprocher les coûts mesurés des factures et exports fournisseurs (OpenAI, Anthropic, AWS CUR, Azure, GCP, CSV générique).
- Agréger par tenant, projet, agent, modèle, fournisseur, type d'usage, jour.
Position et interactions
- Chemin d'exécution : ingestion d'événements (API synchrone et batch), workflow de rapprochement.
- Sources : callbacks LiteLLM, comptabilité GPU locale (H-04), workers média, Tool Gateway, connecteurs, sandbox, opérations asynchrones.
- Le rapprochement associe d'abord provider_request_id, puis des agrégats fournisseur + modèle + jour.
Part open source et part développée
- Open source réutilisé : PostgreSQL, ClickHouse.
- Développé dans la plateforme : Service de registre, analyseurs de factures, rapprochement et rapport d'écarts.
Modèle de données
Tables du Control Plane (schéma eai) : eai.usage_events, ClickHouse usage_events, eai.recon_imports, eai.fx_rates, eai.price_cards.
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) :
| Montant | Quand | Source | Usage |
|---|---|---|---|
| Coût estimé | Avant exécution | Moteur 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écution | Moteur 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 GPU | Showback, confirmation des quotas, tableaux de bord |
| Coût facturé | Après rapprochement | Factures et exports fournisseurs importés dans /v1/finops/reconciliation/imports | Finance, 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_billingindique comment le fournisseur les facture :included_in_output(les tokens de raisonnement sont déjà inclus dansoutput_tokens, ils sont stockés dansunits.reasoning_tokenspour 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_tokensetcache_write_tokenssont 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 dansinput_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_idetpricing.effective_atsont 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 parpricing.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/usageou/v1/finops/usage/batch.
Endpoints d'API
| Méthode | Chemin |
|---|---|
| POST | /v1/finops/usage |
| POST | /v1/finops/usage/batch |
| GET | /v1/finops/usage |
| GET | /v1/finops/summary |
| POST | /v1/finops/reconciliation/imports |
| POST | /v1/finops/reconciliation/run |
| GET | /v1/finops/reconciliation/report |
POST /v1/finops/usage
Requête :
{
"schema_version": "1.0",
"event_id": "use_01J",
"occurred_at": "2026-10-29T10:00:01Z",
"tenant_id": "8f0c2a4e-1b7d-4c55-9a51-2f3e6d7c9b10",
"agent_id": "agt_ap",
"session_id": "ses_9",
"task_id": "tsk_3",
"usage_type": "llm",
"provider": "openai",
"model_id": "gpt-4o-mini",
"region": "eu-west",
"units": {
"input_tokens": 812,
"cached_input_tokens": 512,
"output_tokens": 240,
"reasoning_tokens": 120
},
"pricing": {
"pricing_version": "openai-2026-10-01",
"currency": "USD",
"effective_at": "2026-10-01T00:00:00Z",
"reasoning_billing": "included_in_output"
},
"cost": {
"estimated": {
"amount": "0.000310",
"currency": "USD"
},
"measured": null,
"billed": null
},
"source": "litellm"
}
Réponse :
{
"event_id": "use_01J",
"cost": {
"estimated": {
"amount": "0.000310",
"currency": "USD"
},
"measured": {
"amount": "0.000228",
"currency": "USD"
},
"billed": null
},
"duplicate": false
}
POST /v1/finops/usage/batch
Requête :
{
"events": [
"<Usage Event>",
"<Usage Event>"
]
}
Réponse :
{
"accepted": 2,
"duplicates": 0,
"rejected": []
}
GET /v1/finops/usage
Réponse :
{
"items": [
{
"event_id": "use_01J",
"usage_type": "llm",
"cost": {
"measured": {
"amount": "0.000228",
"currency": "USD"
}
}
}
],
"next_cursor": null
}
GET /v1/finops/summary
Réponse :
{
"group_by": "agent",
"currency": "EUR",
"items": [
{
"key": "agt_ap",
"estimated": "41.20",
"measured": "38.75",
"billed": "39.02"
}
]
}
POST /v1/finops/reconciliation/imports
Requête :
{
"source": "openai",
"filename": "openai-costs-2026-09.csv",
"content": "date,model,cost_usd\n2026-09-01,gpt-4o-mini,12.40\n"
}
Réponse :
{
"id": "rim_2",
"status": "parsed",
"lines": 1840,
"total": {
"amount": "1932.11",
"currency": "USD"
}
}
POST /v1/finops/reconciliation/run
Requête :
{
"from": "2026-09-01",
"to": "2026-09-30"
}
Réponse :
{
"status": "completed",
"matched_events": 182033,
"unmatched_lines": 4
}
GET /v1/finops/reconciliation/report
Réponse :
{
"period": "2026-09",
"currency": "EUR",
"estimated": "1820.40",
"measured": "1771.02",
"billed": "1783.55",
"variance_measured_vs_billed": "0.71%",
"alerts": []
}
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.finops.ingest_usage({"usage_type": "llm", "provider": "openai", "model_id": "gpt-4o-mini", "units": {"input_tokens": 812, "output_tokens": 240}})
client.finops.ingest_usage_batch(events=events)
client.finops.usage(agent_id="agt_ap", limit=100)
client.finops.summary(group_by="agent")
client.finops.import_invoice("openai", open("openai-2026-09.csv").read(), filename="openai-2026-09.csv")
client.finops.reconcile(start="2026-09-01", end="2026-09-30")
client.finops.reconciliation_report(start="2026-09-01", end="2026-09-30")
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.finops.usage.ingest({ usage_type: "llm", provider: "openai", model_id: "gpt-4o-mini", units: { input_tokens: 812, output_tokens: 240 } });
await client.finops.usage.ingestBatch(events);
await client.finops.usage.list({ agent_id: "agt_ap", limit: 100 });
await client.finops.summary({ group_by: "agent" });
await client.finops.reconciliation.createImport({ source: "openai", filename: "openai-2026-09.csv", content: csvText });
await client.finops.reconciliation.run({ from: "2026-09-01", to: "2026-09-30" });
await client.finops.reconciliation.report({ from: "2026-09-01", to: "2026-09-30" });
Java
EnterpriseAIClient client = EnterpriseAIClient.builder()
.baseUrl("<control-plane-url>")
.apiKey(System.getenv("EAI_API_KEY"))
.tenant("acme")
.locale(Locale.FRENCH)
.build();
client.finops().ingestUsage(Map.of("usage_type", "llm", "provider", "openai", "model_id", "gpt-4o-mini", "units", Map.of("input_tokens", 812, "output_tokens", 240)));
client.finops().ingestUsageBatch(Map.of("events", events));
client.finops().listUsage(Map.of("agent_id", "agt_ap", "limit", 100));
client.finops().summary(Map.of("group_by", "agent"));
client.finops().importReconciliation(Map.of("source", "openai", "filename", "openai-2026-09.csv", "content", "date,model,cost\n2026-09-01,gpt-4o-mini,12.40\n"));
client.finops().runReconciliation(Map.of("from", "2026-09-01", "to", "2026-09-30"));
client.finops().reconciliationReport(Map.of("from", "2026-09-01", "to", "2026-09-30"));
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.FinOps.IngestUsage(ctx, map[string]any{"usage_type": "llm", "provider": "openai", "model_id": "gpt-4o-mini", "units": map[string]any{"input_tokens": 812, "output_tokens": 240}})
_, err = client.FinOps.IngestUsageBatch(ctx, map[string]any{"events": events})
_, err = client.FinOps.ListUsage(ctx, eai.WithQuery(map[string]string{"agent_id": "agt_ap"}), eai.WithLimit(100))
_, err = client.FinOps.Summary(ctx, eai.WithQuery(map[string]string{"group_by": "agent"}))
_, err = client.FinOps.ImportReconciliation(ctx, map[string]any{"provider": "openai", "filename": "openai-2026-09.csv", "content": "date,model,cost\n2026-09-30,gpt-4o-mini,12.40\n"})
_, err = client.FinOps.RunReconciliation(ctx, map[string]any{"from": "2026-09-01", "to": "2026-09-30"})
_, err = client.FinOps.ReconciliationReport(ctx, eai.WithQuery(map[string]string{"from": "2026-09-01", "to": "2026-09-30"}))
// 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, EAI_CLICKHOUSE_URL.
Clés de valeurs Helm (deploy/helm/enterprise-ai/values.yaml) : controlPlane.config.EAI_CLICKHOUSE_URL, controlPlane.migration.
Sécurité
- Les Usage Events sont en ajout seul ; les corrections sont de nouveaux événements avec labels.correction_of.
- Scope finops:read pour la lecture, rôle finops pour les imports.
- 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é
- Débit d'ingestion, doublons, lignes de facture non rapprochées, écart par fournisseur.
- traceparent W3C propagé, X-Request-Id renvoyé, les spans portent eai.tenant_id, eai.project, eai.agent_id, eai.run_id.
Unités FinOps
- Le registre est la source comptable ; les traces Opik et les spend logs LiteLLM sont des entrées, pas le registre.
Modes de défaillance et résilience
- event_id en double -> 200 avec duplicate=true, aucun double comptage.
- Retard de ClickHouse -> PostgreSQL reste la référence.
Adoption autonome
Un client peut pousser ses propres Usage Events (autres outils IA) pour une vue consolidée des coûts IA.
Tests d'acceptation
- Renvoyer un événement avec la même Idempotency-Key renvoie le même résultat.
- Un export OpenAI d'un mois se rapproche avec un écart inférieur au seuil
variance_threshold_pctdu tenant (eai.finops_settings).