Ir al contenido
ErmisAI

AI usage accounting, guardrails and metering

How every AI call is accounted, the ledger, spend caps, and what happens when a guardrail trips.

Every model call in ErmisAI passes through the same three-part path: an admission check before the call, a telemetry and accounting middleware around it, and a durable ai_usage_ledger row after it. Overage, if a tenant has it enabled, is derived from that ledger and pushed to Polar through an outbox.

This page covers the accounting side. For provider modes, stages, model resolution and prompts, see AI runtime.

All paths are relative to the app repository root (the directory is named hermesai; the package is ermisai).

What is billable

Three surfaces charge a tenant's monthly AI capacity. Nothing else does.

SurfaceCall sitebillingScopebillable
tenant_story_chatsrc/app/api/stories/[storyId]/chat/route.ts:593tenanttrue
tenant_story_completionsrc/app/api/stories/[storyId]/completion/route.ts:266tenanttrue
tenant_draft_composesrc/app/api/stories/[storyId]/draft/compose/route.ts:147tenanttrue
pipeline_clustersrc/lib/services/rss-aggregation.ts:2553platformfalse
pipeline_synthesissrc/lib/services/rss-aggregation.ts:2959platformfalse
unscoped— (default when no invocation context is set)false

The surface list is the pgEnum ai_surface (src/lib/ai/invocation-context.ts:7-14, src/lib/db/schema.ts:120).

The RSS ingestion pipeline — clustering and synthesis — is the dominant AI spend on the platform, and it never touches a tenant envelope. Both stages run with tenantScopeId: 'platform:shared' and billingScope: 'platform'. They do count toward the platform-wide spend cap. Any statement of the form "every AI call accrues against your capacity" is wrong.

billable_cost_cents_usd is set to the resolved cost only when billable && billingScope === 'tenant'; otherwise it is written as 0 (src/lib/ai/usage-accounting.ts:525-528). That column is the sole input to envelope enforcement, overage metering, and the platform spend cap.

The invocation context

Every call site wraps its model call in runWithAiInvocationContext(...) (src/lib/ai/invocation-context.ts:60-78), an AsyncLocalStorage store carrying invocationId, tenantScopeId, userId, actorType, billingScope, billable, surface, stage, storyId, chatId, clusterId, workflowMode and routeKey.

The guardrail and the provider middleware both decide "is this tenant spend?" through one shared predicate, isBillableTenantInvocation (src/lib/ai/invocation-context.ts:49-57), so the reservation taken at admission and the release taken at the terminal event can never disagree.

A model call with no context still runs. The middleware emits an ai_model_invocation_warning carrying the literal warning code unscoped_invocation (src/lib/ai/providers.ts:542-548, :733-739), and the ledger row is recorded with surface: 'unscoped'. The admin panel surfaces the count as the Unscoped warnings metric card.

Admission: assertAiInvocationAllowed

src/lib/ai/usage-guardrails.ts:400-627. Runs before the model call, in exactly this order.

Operator kill switches (:413-439). aiPaused throws AiPausedError; otherwise the platform daily/monthly spend cap is evaluated and can throw AiPlatformSpendCapReachedError. Both write a blocked ledger row first.

These two apply to every invocation, including non-billable platform pipeline runs, and they apply even when aiGuardrailsMode is disabled. There is no observe/shadow variant — they always hard-block. Pipeline callers degrade to their deterministic fallbacks when this throws.

Mode and scope short-circuit (:441-446). If aiGuardrailsMode === 'disabled', or there is no tenantScopeId, or there is no userId, admission returns { wouldBlock: false } and nothing below runs.

Load policy and period snapshot (:448-459). getTenantAiUsagePolicy(tenantScopeId) resolves the plan limits and billing window; getTenantAiBillingPeriodSnapshot aggregates the ledger over that window.

Reserve in-flight spend (:466-483). For billable tenant invocations only, a fixed cost estimate is added to a Redis counter (INCRBY) before the projected total is read, so two concurrent admissions observe each other rather than both passing the same pre-reserve snapshot.

Default 25 USD cents, TTL 10 minutes, tunable via ERMIS_AI_INFLIGHT_RESERVE_CENTS (0 disables it) — src/lib/ai/usage-guardrails.ts:195-220. The TTL is deliberately longer than the 300 s maximum invocation duration so a reservation leaked by a mid-stream disconnect self-heals.

Compute projected spend (:490-492):

projectedSpendCents = round((summary.billableCostCentsUsd + inFlightReservedCents) * usdToEurRate)

Envelope violations, first match wins (:494-513):

  1. subscriptionBlocksAiAccess(policy.status)AiSubscriptionBillingBlockedError
  2. !policy.envelopeConfiguredAiEnvelopeNotConfiguredError
  3. aiMonthlyHardLimitCents !== null && projected >= hardLimitAiMonthlyHardLimitReachedError
  4. !aiOverageEnabled && projected >= included && included > 0AiOverageDisabledError

Rate and concurrency limits (:515-565), in order: tenant sliding-window requests per minute (ai:tenant:rpm), user sliding-window requests per 5 hours (ai:user:rp5h), user spend counter per 5 hours, and — for operation === 'stream' only — the concurrent-stream counter.

Decide. In enforce (:574-602): release the reservation this call made, write a blocked ledger row, roll back the stream slot, throw. In observe (:604-626): return { wouldBlock: true, mode, code } and let the call proceed.

Per-plan limits

Hardcoded per billing plan in src/lib/ai/usage-policy.ts:27-83. These are not configurable per tenant.

PlanTenant req/minConcurrent streamsUser req / 5 hUser spend ¢ / 5 h
individual_free201120400
individual_plus6033002 500
individual_pro120880012 000
business_plus6033002 500
business_pro120880012 000
enterprise240202 00050 000
internal_unlimited1 000100100 0001 000 000 000

The rest of the policy is derived from the subscription (usage-policy.ts:147-200):

  • includedCentsMonthly = max(subscription.costEnvelopePerMonthCents, 0) — already standing-aware and override-aware from the billing DTO.
  • aiMonthlyHardLimitCents = subscription.aiMonthlyHardLimitCents ?? (aiOverageEnabled ? null : includedCentsMonthly). With overage off — the default for every plan — the envelope is the hard limit.
  • envelopeConfigured is false only when the envelope is 0 and there is no explicit hard limit and overage is off. That is the enterprise "operator has not set an override yet" case.
  • internal_unlimited short-circuits to aiOverageEnabled: true, aiMonthlyHardLimitCents: null, and an envelope floored at 1 000 000 000 cents (:159-174).

The billing window is recomputed, not read

resolveEffectiveBillingPeriod(currentPeriodEndIso, now) (usage-policy.ts:106-145) does not trust the stored current_period_end. It treats the anchor's day-of-month as the billing anniversary and walks in clamped calendar months to the single window where start <= now < end. An unparseable anchor falls back to a trailing 30 days.

This exists because the stored value is routinely outside the current window: free tenants get a one-shot now + 30 days bootstrap that no Polar webhook ever advances, paid tenants can miss a renewal webhook, and annual plans store an end up to a year ahead. Reading it literally put all new usage outside the window and silently disabled the envelope.

Guardrail errors

All defined in src/lib/ai/usage-errors.ts. Messages are hardcoded English and are not routed through next-intl, so a newsroom on a non-English locale sees the raw string.

CodeHTTPMessage
ai_usage_limit_reached429AI usage limit reached for the current window.
ai_user_window_limit_reached429AI usage limit reached for the current user window.
ai_concurrent_stream_limit_reached429Too many concurrent AI streams are active for this tenant.
ai_platform_spend_cap_reached429The platform-wide daily / monthly AI spend limit has been reached. Please try again later.
ai_overage_disabled402AI usage is above the included allowance and overage is disabled.
ai_monthly_hard_limit_reached402AI monthly hard limit reached for the current billing period.
ai_envelope_not_configured402AI capacity has not been configured for this workspace yet. Contact support to activate your contracted monthly envelope.
ai_subscription_billing_blocked402AI usage is blocked because the subscription billing state is not active.
ai_paused503AI features are temporarily paused. Please try again later.

The user-spend variant of ai_user_window_limit_reached carries a different message on the same code: "AI usage spend limit reached for the current user window." (usage-guardrails.ts:543-547).

The three guardrail modes

aiGuardrailsMode is persisted admin runtime config, seeded from ERMIS_AI_GUARDRAILS_MODE (default enforce).

ModePer-tenant checksOn violationblocked ledger row
enforcerunthrowswritten
observeruncall proceeds; returns wouldBlock: truenot written
disabledskipped entirely

In observe mode nothing is rejected and no blocked row is written — only enforce calls recordBlockedAiInvocation (usage-guardrails.ts:585-595). If you are measuring would-block rates in observe mode, the ledger is not where they land.

Observe mode also deliberately does not release the in-flight reservation or the stream slot: the provider middleware owns the single release at the terminal event. For a stream-limit violation the slot is force-reacquired with no limit (:604-620) so the counters stay symmetric with the middleware's unconditional decrement.

A disabled mode resolved inside a Vercel deployment emits exactly one loud stderr warning at config load (src/lib/platform/ai-runtime-config.ts:288-316). It is a warning, not an alert. Monthly limits, overage gating, standing blocks and every rate limit are bypassed.

The ledger

Table ai_usage_ledger, src/lib/db/schema.ts:785-834. One row per AI invocation.

GroupColumns
Identityid (client-supplied PK), invocation_id, request_id, generation_id
Attributiontenant_scope_id, user_id, actor_type, billing_scope, billable, surface, stage, operation, route_key, workflow_mode, story_id, chat_id, cluster_id
Modelprovider_mode, provider_id, model_id
Outcomestatus (completed | failed | blocked | aborted), finish_reason, provider_status_code, error_code, duration_ms
Tokensinput_tokens, output_tokens, total_tokens, cached_input_tokens, cache_write_tokens, reasoning_tokens
Costactual_cost_cents_usd, estimated_cost_cents_usd, billable_cost_cents_usd, cost_source (actual | estimated | none)
Timestampscreated_at, finished_at

Three indexes (schema.ts:828-832): (tenant_scope_id, finished_at), (user_id, finished_at), and (finished_at) alone — the last one exists because platform-wide aggregates filter on finished_at with an unconstrained leading column, which the composite indexes cannot serve.

Writes are gated on aiUsageLedgerEnabled in the runtime config. recordBlockedAiInvocation additionally returns null when there is no invocation context (usage-accounting.ts:537-556), so a blocked unscoped call produces no row.

Cost resolution

resolveActualOrEstimatedCost (src/lib/ai/usage-accounting.ts:415-459), in order:

  1. Gateway-reported actual USD cost from provider metadata → cost_source: 'actual'.
  2. Otherwise the estimate from the hand-maintained price cards → cost_source: 'estimated', but only when it is greater than zero.
  3. Otherwise zero → cost_source: 'none'.

src/lib/ai/provider-pricing.ts carries price cards for 18 model ids. calculateEstimatedCostCents returns 0 when no card matches (provider-pricing.ts:165-170). A model that is not in the table and whose provider does not report an actual cost accrues zero spend: it does not consume the envelope, does not trigger the hard limit, and meters nothing. Adding a model to the catalog without adding a price card silently makes it free.

The estimator peels cache-read and cache-write tokens off the provider-reported input total before charging the base input rate, because the reported total is inclusive of both (provider-pricing.ts:175-177).

Durability: why billable writes block the response

persistAiUsageLedgerEntryWithDurability (src/lib/ai/providers.ts:452-514) splits on isBillableTenantInvocation:

  • Non-billable — the ledger write is scheduled post-response via the accounting task scheduler.
  • Billable tenant — the row is built once, in request context (capturing a stable id and finishedAt), then inserted inline and awaited within the request or stream lifetime.

The reason is stated in the source: serverless can terminate a post-response task when the response ends, and this is the spend that drives the envelope, the hard limit, and Polar metering. Losing it is worse than adding latency. A failed inline write is logged loudly but never fails the user's generation — the content has already been produced.

On insert failure the already-built row (same id) is pushed to the Redis retry queue by enqueueFailedAiLedgerEntry, and the middleware emits an ai_model_invocation_warning with a durable_accounting_failed:* warning code.

The in-flight reservation is released in the finally of that path, through a synchronous once-claim (claimAiInFlightReservationRelease, invocation-context.ts:92-101) shared with the compose cache-hit path, so two terminal events cannot both decrement and consume a different call's reserved cents.

The ledger retry queue

src/lib/ai/ledger-retry-queue.ts. Upstash Redis lists — deliberately a different plane from the failing dependency, because putting the retry queue in the same Postgres that just rejected the write provides no resilience.

KeyPurpose
ermis:ai:ledger:retry:queueFIFO queue of failed rows
ermis:ai:ledger:retry:processingclaim list; reclaimed by the next run if a redrive crashes mid-flight
ermis:ai:ledger:retry:dlqentries past MAX_ATTEMPTS

MAX_ATTEMPTS = 10, default redrive batch 100 (:23-24). Claims use LMOVE rather than RPOP so a crash after the claim leaves the entry recoverable. The contract is at-least-once, which is safe because the payload carries the stable client-supplied id: a re-insert is a primary-key no-op, never a second counted row.

purgeQueuedLedgerEntriesForTenantScope (:288) scrubs all three lists during GDPR erasure (src/lib/db/data-erasure-repository.ts:279), so a redrive cannot resurrect deleted usage.

Without Upstash configured, enqueueFailedAiLedgerEntry writes a stderr line and returns false — the row is gone. Redis is not optional for correct accounting in production.

Metering overage to Polar

After a committed billable ledger row, persistBuiltAiUsageLedgerEntry (usage-accounting.ts:621-641) calls enqueueAiMeteringForLedgerRow when aiMeteringEnabled is set. The call is isolated in a try/catch that logs and swallows: the ledger row is already committed, metering is delta-based and reconcilable from the ledger, and a post-commit throw would make the durable wrapper re-enqueue an already-persisted row.

src/lib/billing/ai-metering.ts:35-143. A row is only considered when it is billable, billingScope === 'tenant', status === 'completed', has a tenant scope, and has billableCostCentsUsd > 0.

Inside one transaction holding pg_advisory_xact_lock(hashtext(tenantScopeId)):

  1. Sum cumulative billable USD cents for the billing window, with a strict (finishedAt, id) upper bound so two same-millisecond completions cannot both count each other.
  2. Convert to EUR with resolveUsdToEurRate() — the same rate the admission guardrail uses, so enforcement and billing agree on where the envelope ends.
  3. targetOverage = max(cumulativeSpendEur - includedCentsMonthly, 0).
  4. delta = max(targetOverage - alreadyQueuedOverage, 0). Zero or less: stop.
  5. If policy.aiOverageEnabled is false: stop and enqueue nothing.
  6. Insert one row into ai_usage_metering_outbox with externalEventId = ledgerRowId.

The advisory lock is transaction-scoped (required on a transaction-mode pooler) and auto-releases at commit. The threshold notification runs after the transaction so the lock is never held across notification I/O.

Overage is opt-in per tenant. ai_overage_enabled defaults to false and the only write path is PUT /api/admin/billing/ai-controls (super_admin only). With it off, spend past the envelope is never sent to Polar — the excess stays unbilled by design, because concurrent admitted calls can land past the envelope during the reservation race window and nothing should charge for that. The tenant is blocked, not billed.

The outbox and its drain

Table ai_usage_metering_outbox (src/lib/db/schema.ts:836-859): ledger_id (FK, cascade), tenant_scope_id, external_event_id (unique index), meter_name, quantity_cents, status, attempt_count, provider_event_id, last_error, processed_at.

flushAiMeteringOutbox (ai-metering.ts:146-198) drains it. The loop is deliberately serial — one Polar request at a time, and the processed/failed mark lands before the next row is claimed, so a crash re-delivers at most the in-flight row. With POLAR_ACCESS_TOKEN absent it no-ops with reason: 'polar_not_configured'.

The meter shape (ai-metering.ts:15-33):

{
  "name": "ai_usage.cents.v1",
  "externalCustomerId": "org:<clerkOrgId>",
  "externalId": "<ledgerRowId>",
  "metadata": { "quantity_cents": 137, "source": "ermisai-ai-ledger" }
}

Polar deduplicates on externalId, which is why the ledger row id is used verbatim.

MAX_AI_METERING_ATTEMPTS = 5 (src/lib/db/ai-usage-repository.ts:171). listDeliverableAiMeteringOutboxRows filters on attemptCount < 5, so a row that fails five times is parked permanently as silently unbilled overage. Nothing retries it, and there is no alert for it beyond the cron's reportCronDrainFailures on that run. Recovery is an operator calling POST /api/admin/ai/metering/requeue, which resets the attempt counters (src/lib/db/ai-usage-repository.ts:237-247).

Crons and manual redrives

Two entries in vercel.json, both every two minutes:

SchedulePathDrains
*/2 * * * */api/admin/ai/metering/flushmetering outbox → Polar
*/2 * * * */api/admin/ai/ledger/retry/flushRedis ledger retry queue → Postgres

Both routes carry two handlers. GET is the cron path, authorized by isAuthorizedCronRequest (the CRON_SECRET bearer check). POST is the human path, gated on canAccessAdminSurface(appRole, 'ai')super_admin only.

Without CRON_SECRET set, the cron GET returns 401. The metering outbox then fills and no overage ever reaches Polar. This is a silent revenue failure: the app works normally, tenants are enforced correctly, and nothing surfaces it except the growing outbox.

The manual operator surface for all of this:

ActionRouteRole surface
Flush metering backlogPOST /api/admin/ai/metering/flushai (super_admin)
Requeue dead metering rowsPOST /api/admin/ai/metering/requeueai (super_admin)
Redrive ledger retry queuePOST /api/admin/ai/ledger/retry/flushai (super_admin)

The Requeue dead metering rows button lives on /admin/costs, which ops operators can open — but its endpoint is gated on the ai surface, which ops cannot use. An ops operator sees the button and gets a 403.

Threshold notifications

src/lib/ai/usage-threshold-notifier.ts fires at 50%, 80% and 100% of the envelope, computed from the same EUR-converted cumulative spend the metering step produced. Each (billingPeriodKey, threshold) pair is recorded with an atomic HSETNX (addHashRecordIfAbsent) before the notification is emitted, so two concurrent billable completions cannot double-fire. A new billing period uses new hash fields, so the next period re-fires.

Titles and bodies are hardcoded English (:50-71), not next-intl:

  • 50% — 50% of monthly AI capacity used
  • 80% — 80% of monthly AI capacity used
  • 100% — Monthly AI capacity reached

At 80% and 100% an upsell email is also dispatched (usage-threshold-notifier.ts:118-128), but only to individual_free and individual_plus tenants whose subscription status is free or grants entitlements (src/lib/email/upsell-trigger.ts:12, :36-52). The dispatcher is fully failure-isolated so a send error never affects the metering path.

An envelope of 0 produces no thresholds at all (computeCrossedThresholds returns an empty list when envelopeCents <= 0).

Platform-wide spend cap

src/lib/ai/platform-spend.ts. Independent of any tenant envelope: a total ceiling on sum(billable_cost_cents_usd) across all tenants.

  • Windows are UTC start-of-day and UTC start-of-month.
  • Caps come from the runtime config (platformDailySpendCapCents / platformMonthlySpendCapCents), seeded from ERMIS_PLATFORM_AI_DAILY_CAP_CENTS / ERMIS_PLATFORM_AI_MONTHLY_CAP_CENTS. null means no cap.
  • Only a window whose cap is non-null is queried, so an all-null config costs zero ledger reads.
  • Results are cached 60 s per window, so the check adds at most one aggregate query per minute to the dispatch path.
  • Exceeded when spend >= cap.

The cap fails open. resolvePlatformAiSpendCapError wraps the whole snapshot read in a try/catch that returns null on any error (usage-guardrails.ts:374-397) — a ledger read failure must never take AI down. A degraded Postgres therefore disables the platform ceiling without any user-visible signal.

Note also that the caps are USD cents, matching the raw ledger, while every tenant envelope and hard limit is EUR cents. The two are not the same unit.

Currency

Plan prices, envelopes, overrides and hard limits are EUR cents. The ledger accrues USD cents (provider cost). There is exactly one conversion function, resolveUsdToEurRate() (src/lib/ai/usage-fx.ts:14-28), read from ERMIS_USD_TO_EUR_RATE, defaulting to 1.0. A non-finite or non-positive value also falls back to 1.

It is deliberately a dependency-free leaf module: both the guardrail and the metering layer need it, and importing it from either created a usage-accounting → ai-metering → usage-guardrails → usage-accounting cycle.

Three call sites use it, and because they all use the same one they agree with each other:

  1. The admission guardrail's projectedSpendCents (usage-guardrails.ts:490-492).
  2. The overage metering delta (ai-metering.ts:100-102).
  3. The tenant usage snapshot shown in the workspace (src/lib/ai/tenant-usage-summary.ts:83-102).

They are consistent, but they are not currency-accurate until an operator sets a real rate.

Failure modes worth knowing

ConditionBehaviour
Redis unavailable at admissionIn-flight reservation is skipped and the call proceeds (usage-guardrails.ts:473-483). Sliding-window limits fall back to a per-process in-memory counter that is explicitly not cross-instance coherent (:66-90).
Redis unavailable at ledger-write failureThe row cannot be enqueued and is lost (ledger-retry-queue.ts:102-108).
Postgres unavailable at ledger writeRow goes to the Redis retry queue with its stable id; the cron redrives it once Postgres recovers.
Ledger read fails during the platform cap checkFails open — AI proceeds with no ceiling.
Metering enqueue throws after ledger commitLogged to stderr, swallowed; self-corrects on the next billable row because the delta is recomputed cumulatively.
Model has no price card and the provider reports no costcost_source: 'none', zero spend, zero metering.
aiGuardrailsMode: 'disabled' in a deploymentOne stderr warning at config load. All per-tenant checks bypassed; aiPaused and the platform cap still apply.
Tenant subscription is past_due or canceledAiSubscriptionBillingBlockedError (402) on every tenant-billable call, and the whole workspace reverts to Free-plan limits.
Enterprise tenant with no envelope overrideAiEnvelopeNotConfiguredError (402) on the very first call — the catalog envelope is null, which resolves to 0.

Operator surfaces

/admin/aiAI usage operations (super_admin only, src/lib/auth/platform-roles.ts:32) has the buttons Refresh usage summary and Flush metering backlog, and the metric cards Tenant billable, Platform cost, Blocked requests, Failed requests, Pending metering, Failed metering, Unscoped warnings, Snapshot generated, plus Ledger anomalies and Provider operations readiness alerts. The Provider stage footprint section below it charts ledger-backed provider and stage costs.

/admin/costs (super_admin and ops) groups ai_usage_ledger by provider_id, stage and billing_scope. Two caveats: no date range is passed, so the figures are all-time totals; and they are USD, while the tenant envelopes on /admin/tenants are EUR.

/admin/tenants/[tenantId] (super_admin only) is where the three per-tenant billing controls live: the Overage enabled (metered billing beyond the included envelope) checkbox, Monthly hard limit (EUR, empty = plan default), and Envelope override (EUR/month, empty = plan envelope; REQUIRED for enterprise). updateTenantBillingAiControlsInDb (src/lib/db/billing-repository.ts:585) is the only write path for those three columns; undefined leaves a field unchanged, explicit null clears it.

Environment variables

VariableDefaultEffect
ERMIS_AI_GUARDRAILS_MODEenforcedisabled | observe | enforce. Seed default only — persisted admin config wins once set.
ERMIS_AI_METERING_ENABLEDtrueSeed default for the Polar metering outbox.
ERMIS_AI_PAUSEDfalseSeed default for the global kill switch.
ERMIS_PLATFORM_AI_DAILY_CAP_CENTSunset (no cap)USD cents.
ERMIS_PLATFORM_AI_MONTHLY_CAP_CENTSunset (no cap)USD cents.
ERMIS_AI_INFLIGHT_RESERVE_CENTS25USD cents held per in-flight billable call; 0 disables.
ERMIS_USD_TO_EUR_RATE1Applied at all three USD→EUR boundaries.
CRON_SECRETRequired by both drain crons. Unset means overage never reaches Polar.
POLAR_ACCESS_TOKENAbsent means the metering drain no-ops with polar_not_configured.

Everything except ERMIS_USD_TO_EUR_RATE, ERMIS_AI_INFLIGHT_RESERVE_CENTS, CRON_SECRET and POLAR_ACCESS_TOKEN above is a seed default. The persisted admin runtime config in platform_state under platform:admin:ai-runtime-config is authoritative once written, and it is edited only through PUT /api/admin/ai/config. Changing the env var in Vercel will not move a value that has already been persisted.

En esta página