Aller au contenu principal

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) :

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/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​

  1. Renvoyer un événement avec la même Idempotency-Key renvoie le même résultat.
  2. Un export OpenAI d'un mois se rapproche avec un écart inférieur au seuil variance_threshold_pct du tenant (eai.finops_settings).

Feuille de route d'implémentation