Skip to main content

Quotas and Billing

Every AI call Reveal makes spends tokens on the developer's provider account. The quota gate lets the developer decide, per call and per user, whether that spend is allowed, and records what it actually cost. Use it to enforce plan entitlements, token budgets, and rate limits, and to debit actual usage against a balance. To only observe usage, without controlling it, use usage tracking instead.

The gate has two halves that receive the same call context:

  • Check runs before tokens are spent. Verify that the user's plan includes the feature or profile and that budget remains. A denied call never reaches the provider.
  • Settle runs after the call completes or fails, with the token counts the provider reported. Debit them against the balance here, or release a reservation made during the check.

Both halves receive the full user context, so per-request claims the developer's IRVUserContextProvider stores in Properties (tenant ID, plan, cost center) are available at check time and at billing time.

Implement the gate​

Implement IAIQuotaGate:

using Reveal.Sdk.AI;

public interface IAIQuotaGate
{
ValueTask<AIGateDecision> CheckAsync(AICallContext context, CancellationToken cancellationToken);

// Optional: the default implementation does nothing.
ValueTask SettleAsync(AICallContext context, AIUsageRecord actual, CancellationToken cancellationToken);
}

The following gate restricts profiles by plan, enforces a token budget per tenant, and debits actual usage:

PlanBillingGate.cs
using Reveal.Sdk.AI;

public sealed class PlanBillingGate : IAIQuotaGate
{
private readonly IPlanStore _plans;
private readonly IUsageStore _usage;

public PlanBillingGate(IPlanStore plans, IUsageStore usage)
{
_plans = plans;
_usage = usage;
}

public async ValueTask<AIGateDecision> CheckAsync(AICallContext context, CancellationToken cancellationToken)
{
var tenant = GetClaim(context, "TenantId");
// Retrieve the plan from custom storage
var plan = await _plans.GetAsync(tenant, context.User.UserId, cancellationToken);

if (!plan.AllowsProfile(context.Profile))
{
return AIGateDecision.Deny(AIDenialReason.NotEntitled,
"This model isn't included in your plan.");
}

// Check the balance in custom storage
if (await _usage.RemainingTokensAsync(tenant, cancellationToken) <= 0)
{
return AIGateDecision.Deny(AIDenialReason.QuotaExceeded,
"You've reached today's AI limit. Upgrade your plan or try again tomorrow.",
retryAfter: TimeSpan.FromHours(1));
}

return AIGateDecision.Allow();
}

// Debit the balance in custom storage
public ValueTask SettleAsync(AICallContext context, AIUsageRecord actual, CancellationToken cancellationToken)
=> _usage.DebitAsync(
tenant: GetClaim(context, "TenantId"),
callId: context.CallId, // idempotency key
tokens: actual.TotalTokens ?? 0, // null when the provider reported no usage
cancellationToken);

private static string? GetClaim(AICallContext context, string key)
=> context.User.Properties?.TryGetValue(key, out var value) == true ? value?.ToString() : null;
}

Register the gate​

Program.cs
builder.Services.AddRevealAI()
.AddOpenAI()
.AddQuotaGate<PlanBillingGate>();

The gate is registered as a singleton. Ensure the implementation is thread-safe and inject only singleton-safe dependencies. Only one gate is supported; registering a second replaces the first.

Call context​

The check and the settle receive the same context. In Node.js and Java the property names are camel-cased (operationId, callId, ...), and the user is a { userId, properties } object.

PropertyDescription
OperationIdGroups the calls of one top-level operation, such as a chat turn or an insight. Stable across the operation, so a per-operation decision can be cached.
CallIdUnique ID of this provider call. The settle receives the same ID; use it as the idempotency key when debiting.
UserThe user context, including UserId and the claims in Properties.
IntentThe workflow the call belongs to, such as generation-build-report or widget-analysis. See Intents.
ProfileThe profile that serves the call, after intent routing.
ProviderThe provider registration name, or the provider type for a connection the user supplied.
ModelThe model the call will use, when known.
IsSystemCalltrue for background work Reveal performs under the system user, such as metadata enrichment and datasource ranking during a dashboard generation.
IsUserConnectiontrue when the call uses a connection the user supplied instead of one the developer configured.

Because the gate sees the routed profile and the intent of every call, the developer can price or restrict specific profiles and features, not just total usage. Gating one intent means gating its related intents too. For example, a dashboard analysis plans and writes its report under dashboard-analysis and analyzes each widget under widget-analysis, so a free tier that blocks widget-analysis should also block dashboard-analysis.

Decisions and client errors​

Return AIGateDecision.Allow() to let the call proceed, or AIGateDecision.Deny(reason, message, retryAfter) to block it. In Node.js and Java, return { allow: true } or { allow: false, reason, message, retryAfterSeconds }.

A denial fails the request. The client receives a ProblemDetails response with a stable code, and message as its detail, so the message must be safe to show users. Messages longer than 500 characters are truncated.

ReasonNode.js / Java valueHTTP statuscodeRetry information
QuotaExceededquotaExceeded429ai.quota_exceededretryAfterSeconds in the response body
RateLimitedrateLimited429ai.rate_limitedRetry-After response header
NotEntitlednotEntitled403ai.not_entitled—
Otherother403ai.denied—

Retry times are rounded up to whole seconds, with a minimum of one. If a streaming response has already started when a call is denied, the stream ends with an error event that carries the same code and retryAfterSeconds.

The client SDK surfaces these responses as a RevealAIError with code, status, and retryAfterSeconds properties. See Error Codes for every code the client can receive:

import { RevealAIError, AIErrorCodes } from '@revealbi/api';

try {
await client.ai.chat.sendMessage({ message, datasourceId: 'sales' });
} catch (error) {
if (error instanceof RevealAIError) {
switch (error.code) {
case AIErrorCodes.QuotaExceeded:
case AIErrorCodes.RateLimited:
showLimitReached(error.message, error.retryAfterSeconds);
break;
case AIErrorCodes.NotEntitled:
showUpgradePrompt(error.message);
break;
default:
showErrorMessage(error.message);
}
}
}

Settling usage​

The settle receives the same context the check saw and an AIUsageRecord with the actual usage: InputTokens, OutputTokens, TotalTokens, ReasoningTokens, CachedInputTokens, Model, Duration, Succeeded, and ErrorCode. Token counts are null when the provider reported no usage, which is distinct from 0. TotalTokens is InputTokens plus OutputTokens; Google Gemini reports reasoning tokens separately from output, so add ReasoningTokens when billing Gemini calls. The record contains no prompt or response text.

  • It runs for every call that passed the check, including calls that failed, timed out, or were canceled. Reservation-based accounting therefore never leaks a reservation. It is not called for denied calls, or when the check itself fails.
  • It runs in the request. Keep it fast: a quick write to the developer's store, or an internal queue flushed separately.
  • Its failures are logged, not propagated. Reveal won't fail a call it has already served because billing failed. If billing writes need guaranteed delivery, make the write durable, for example with a local outbox.
  • Each server settles its own calls, so in a multi-server deployment only the developer's shared balance store needs to be consistent.

Behavior to know​

  • Every provider call made for a user is gated. One user action can make several AI calls; for example, a chat message classifies the request before generating a dashboard. The check runs once per call, and a denial at any step fails the whole operation. Datasource ranking during a dashboard generation is the exception: Reveal runs it as background work, so it follows the next rule.

  • Background work bypasses the gate by default. Calls Reveal makes on its own, such as metadata enrichment and datasource ranking during a dashboard generation, are the developer's spend rather than a user's, so they are not checked or settled. To budget them as well on ASP.NET, set RevealAI:Gate:IncludeSystemCalls to true; those calls arrive with IsSystemCall set to true.

    appsettings.json
    {
    "RevealAI": {
    "Gate": {
    "IncludeSystemCalls": true
    }
    }
    }
  • A failing gate fails the call. On ASP.NET, an exception thrown by CheckAsync fails the request without producing a usage record, and the client receives the exception's message, so keep sensitive details out of it. The message arrives as a generic HTTP 400 error, prefixed by Reveal and without a code; to give the client a stable code, return a denial instead of throwing. A null decision allows the call. In Node.js and Java, a quotaGate callback that throws or returns a missing or malformed decision denies the call with ai.denied: a broken gate is never an open gate.