H-02 Cost ledger
Brick H of the Enterprise AI Platform. Technology: PostgreSQL (eai.usage_events, source of truth, RLS) + ClickHouse usage_events (analytics). French version: ../../fr/bricks/H-02-cost-ledger.md.
Role and responsibilities
- Store every Usage Event once (idempotent on event_id) with estimated, measured and billed cost.
- Reconcile measured costs with provider invoices and exports (OpenAI, Anthropic, AWS CUR, Azure, GCP, generic CSV).
- Aggregate by tenant, project, agent, model, provider, usage type, day.
Position and interactions
- Execution path: event ingestion (sync API and batch), reconciliation workflow.
- Sources: LiteLLM callbacks, local GPU accounting (H-04), media workers, Tool Gateway, connectors, sandbox, async operations.
- Reconciliation matches provider_request_id first, then provider + model + day buckets.
Open source and developed parts
- Open source reused: PostgreSQL, ClickHouse.
- Developed in the platform: Ledger service, invoice parsers, matching and variance report.
Data model
Control Plane tables (schema eai): eai.usage_events, ClickHouse usage_events, eai.recon_imports, eai.fx_rates, eai.price_cards.
Every tenant table has tenant_id uuid NOT NULL and a row level security policy on current_setting('app.tenant_id', true)::uuid. ClickHouse usage_events is the analytics replica of eai.usage_events (ReplacingMergeTree keyed by event_id).
Contract: contracts/v1/usage-event.schema.json (schema_version 1.0). Usage is always reported with contracts/v1/usage-event.schema.json, audit with contracts/v1/audit-event.schema.json.
Estimated, measured and billed cost
Every Usage Event carries three amounts in cost (Usage Event contract, cost.estimated, cost.measured, cost.billed, each a decimal string with a currency or null):
| Amount | When | Source | Used for |
|---|---|---|---|
| Estimated cost | Before execution | Pricing engine (H-03) applied to predicted units (prompt tokens counted, max output tokens, image count, video seconds) | Budget control, quota reservation (H-01), approval thresholds |
| Measured cost | After execution | Pricing engine applied to units really consumed, as reported by the provider response, LiteLLM callback, worker or GPU sampler | Showback, quotas commit, dashboards |
| Billed cost | After reconciliation | Provider invoices and exports imported in /v1/finops/reconciliation/imports | Finance, variance analysis, chargeback |
Rules applied by the pricing engine and the ledger:
- No double counting of reasoning tokens.
pricing.reasoning_billingrecords how the provider bills them:included_in_output(reasoning tokens are already insideoutput_tokens, they are stored inunits.reasoning_tokensfor analysis only and are not priced again),separate(priced with their own rate),not_billed,not_exposed. - Cached tokens.
cached_input_tokens,cache_read_tokensandcache_write_tokensare priced with their own rates of the price card; cached input tokens are subtracted from full price input tokens when the provider reports them insideinput_tokens. - Price applicable at call date. The price card is the one whose effective period contains
occurred_at;pricing.pricing_version,pricing.price_card_idandpricing.effective_atare frozen in the event, so a later price change never rewrites history. - Currency and FX. Amounts keep the provider currency; conversion to the tenant reporting currency uses
eai.fx_ratesof the call date (GET/PUT /v1/finops/fx-rates), and the rate used is stored for audit. - Local model allocation policy. Local models (vLLM, Diffusers, faster-whisper, pyannote, Piper, video GPU workers) have no invoice: their cost comes from an allocation policy (
eai.allocation_policies) combining GPU hours, infrastructure overhead, estimated energy, storage and real usage (GPU-seconds from DCGM samples, tokens served), referenced bypricing.allocation_policy_id. - Event fields. Each event records the tariff applicable at call date, currency, provider, model, region, tenant, agent, session and task (
pricing,provider,model_id,region,tenant_id,agent_id,session_id,task_id), plus project, environment, run, request, provider request and trace ids for correlation. - LiteLLM is a usage source, not the only accounting source. LiteLLM callbacks cover LLM, embeddings and some media calls through providers. Local GPU inference, media workers, tool calls, connectors, sandbox executions and asynchronous operations (batch jobs, video generation) emit their own Usage Events directly to
/v1/finops/usageor/v1/finops/usage/batch.
API endpoints
| Method | Path |
|---|---|
| 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
Request:
{
"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"
}
Response:
{
"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
Request:
{
"events": [
"<Usage Event>",
"<Usage Event>"
]
}
Response:
{
"accepted": 2,
"duplicates": 0,
"rejected": []
}
GET /v1/finops/usage
Response:
{
"items": [
{
"event_id": "use_01J",
"usage_type": "llm",
"cost": {
"measured": {
"amount": "0.000228",
"currency": "USD"
}
}
}
],
"next_cursor": null
}
GET /v1/finops/summary
Response:
{
"group_by": "agent",
"currency": "EUR",
"items": [
{
"key": "agt_ap",
"estimated": "41.20",
"measured": "38.75",
"billed": "39.02"
}
]
}
POST /v1/finops/reconciliation/imports
Request:
{
"source": "openai",
"filename": "openai-costs-2026-09.csv",
"content": "date,model,cost_usd\n2026-09-01,gpt-4o-mini,12.40\n"
}
Response:
{
"id": "rim_2",
"status": "parsed",
"lines": 1840,
"total": {
"amount": "1932.11",
"currency": "USD"
}
}
POST /v1/finops/reconciliation/run
Request:
{
"from": "2026-09-01",
"to": "2026-09-30"
}
Response:
{
"status": "completed",
"matched_events": 182033,
"unmatched_lines": 4
}
GET /v1/finops/reconciliation/report
Response:
{
"period": "2026-09",
"currency": "EUR",
"estimated": "1820.40",
"measured": "1771.02",
"billed": "1783.55",
"variance_measured_vs_billed": "0.71%",
"alerts": []
}
SDK examples
Python
from enterprise_ai import EnterpriseAI
client = EnterpriseAI(base_url="<control-plane-url>", api_key="eai_live_xxx", tenant="acme", locale="en")
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: "en" });
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.ENGLISH)
.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("en"))
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
Environment variables: EAI_DATABASE_URL, EAI_CLICKHOUSE_URL.
Helm values keys (deploy/helm/enterprise-ai/values.yaml): controlPlane.config.EAI_CLICKHOUSE_URL, controlPlane.migration.
Security
- Usage events are append only; corrections are new events with labels.correction_of.
- finops:read scope for readers, finops role for imports.
- Tenant resolved from the credential, never from the body; PostgreSQL row level security on every tenant table.
- Every mutation and policy decision writes a hash chained audit event (G-11).
- Errors use the standard envelope with message_i18n en and fr.
Observability
- Ingestion rate, duplicates, unmatched invoice lines, variance per provider.
- W3C traceparent propagated, X-Request-Id returned, spans carry eai.tenant_id, eai.project, eai.agent_id, eai.run_id.
FinOps units
- The ledger is the accounting source; Opik traces and LiteLLM spend logs are inputs, not the ledger.
Failure modes and resilience
- Duplicate event_id -> 200 with duplicate=true, no double counting.
- ClickHouse lag -> PostgreSQL remains authoritative.
Standalone adoption
A client can push its own Usage Events (other AI tools) to get one consolidated AI cost view.
Acceptance tests
- Re-posting an event with the same Idempotency-Key returns the same result.
- An OpenAI export for a month reconciles with variance below the tenant
variance_threshold_pctthreshold (eai.finops_settings).