Skip to content

Guardrails: gates, limits, and violations

How Paygent's guard checks work: three dimensions (period spend, session spend, model tokens), soft vs hard gates, and pre-flight check methods.

Every LLM call inside a Paygent context (paygent_context in Python, paygentContext in TypeScript) passes through a guard check before reaching the provider. The guard runs three independent checks in microseconds, returns the most restrictive violation, and either lets the call through, fires a callback, or raises an exception — all before a single token leaves your system. This page explains how that system works and how to interact with it.

Three independent checks

The guard always evaluates the same three dimensions:

# Check Compared against Unit
1 Period spend max_spend_per_period dollars
2 Session spend max_spend_per_session dollars
3 Per-model tokens model_limits[model].max_tokens_per_period tokens

Each check is closed under its own unit — the SDK never compares dollars to tokens. A user can hit a model token limit while still well within their dollar cap, and vice versa.

If a limit is not configured (set to null or absent from the plan), that check is skipped entirely — Paygent treats missing limits as inf.

Soft gate vs hard gate

Soft gate Hard gate
When it fires At soft_gate_at fraction (default 80%) At hard_gate_at fraction (default 100%)
Call to LLM Executes normally Blocked before reaching the provider
Tokens consumed Yes No
Cost incurred Yes No
Callback fired on_soft_gate(result) on_hard_gate(result)
Exception raised None PaygentLimitExceeded (if raise_on_hard_gate=True)

The hard gate runs before the original create call. A blocked user costs you nothing — no tokens are sent to the provider.

import logging
from paygent import PaygentLimitExceeded

log = logging.getLogger(__name__)

def on_approaching(result):
    log.warning("Soft gate: %s (%.0f%% used)", result.message, result.usage_pct * 100)

def on_blocked(result):
    log.error("Hard gate: %s (reason=%s)", result.message, result.gate_reason)
    # Your own integrations go here — e.g. surface a banner in your UI,
    # send an upgrade-prompt email, or push to your alerting system.

pg.on_soft_gate(on_approaching)
pg.on_hard_gate(on_blocked)

try:
    with paygent_context(user_id="user_123"):
        response = openai.chat.completions.create(...)
except PaygentLimitExceeded as e:
    # Convert to a 429 response in your handler, or show the user
    # an upgrade prompt — see Callbacks & Events for FastAPI / Flask patterns.
    print(f"Blocked: {e.guard_result.message}")
import { PaygentLimitExceeded } from "@paygentjs/sdk";

pg.onSoftGate((result) => {
  console.warn(`Soft gate: ${result.message} (${Math.round(result.usagePct * 100)}% used)`);
});

pg.onHardGate((result) => {
  console.error(`Hard gate: ${result.message} (reason=${result.gateReason})`);
  // Your own integrations go here — banner, upgrade email, alerting, etc.
});

try {
  await paygentContext({ userId: "user_123" }, () =>
    openai.chat.completions.create({ /* ... */ }),
  );
} catch (err) {
  if (err instanceof PaygentLimitExceeded) {
    // Convert to a 429 in your handler — see Callbacks & Events for
    // Express / Next.js patterns.
    console.log(`Blocked: ${err.guardResult.message}`);
  } else {
    throw err;
  }
}

How the guard picks the most restrictive violation

When more than one dimension is in violation simultaneously, the guard returns a single result:

  1. Hard gate beats soft gate. If period spend is at a soft gate (85%) and model tokens are at a hard gate (102%), the result is hard_gate.
  2. Highest usage_pct wins within the same severity. If two dimensions are both at soft gate, the one with the higher percentage is returned.
# Example: user at 95% on period spend AND 102% on gpt-4o tokens.
# Both trip — hard gate wins. usage_pct of the model check is higher.

GuardResult(
    status="hard_gate",
    gate_reason="model_limit:gpt-4o",
    usage_pct=1.02,
    current_value=51000,
    limit_value=50000,
    message="gpt-4o token limit reached: 51,000 of 50,000",
)

The gate_reason always reflects the single tightest constraint so your callbacks and error messages are maximally actionable.

GuardResult fields

Field Type Description
status "ok" | "soft_gate" | "hard_gate" The guard decision.
gate_reason string | null Which limit fired: "total_spend", "session_spend", or "model_limit:<model>".
usage_pct float current_value / limit_value. Values above 1.0 are possible on hard gates.
current_value float Current spend (dollars) or token count at the time of the check.
limit_value float The limit being checked.
message string | null Human-readable description, e.g. "gpt-4o token limit reached: 51,000 of 50,000".

Field names in TypeScript

The JS SDK uses camelCase: gateReason, usagePct, currentValue, limitValue. The status values ("ok", "soft_gate", "hard_gate") and the gateReason string values below are identical across both SDKs.

gate_reason values

Value What it means
"total_spend" Period dollar cap was reached or approached.
"session_spend" Session dollar cap was reached or approached.
"model_limit:gpt-4o" GPT-4o token limit was reached or approached. Model name is appended after the colon.

Session windows

The session spend check uses a clock-based window. Each window lasts session_timeout_minutes (default 30 min). When the current window expires:

  1. session_cost resets to 0.
  2. session_id rotates to a new UUID.
  3. session_started_at updates to the current time.

Rotation happens automatically during the guard check — you do not need to manage sessions manually. Set session_timeout_minutes in your plan to control how long a session window lasts:

"session_timeout_minutes": 30.0   # $5 per 30-minute window

Each session ID is recorded on every UsageEvent, so you can group events by session on the backend to reconstruct conversation-level cost.

Catching PaygentLimitExceeded

PaygentLimitExceeded is raised at a hard gate. Catch it wherever you handle LLM call failures:

from paygent import PaygentLimitExceeded

try:
    with paygent_context(user_id="user_123"):
        response = openai.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": query}],
        )
except PaygentLimitExceeded as e:
    guard = e.guard_result
    if guard.gate_reason == "total_spend":
        return "You've reached your monthly limit. Upgrade your plan to continue."
    elif guard.gate_reason and guard.gate_reason.startswith("model_limit:"):
        model = guard.gate_reason.split(":")[1]
        return f"You've used all your {model} tokens for this period."
    return "Usage limit reached."
import { PaygentLimitExceeded } from "@paygentjs/sdk";

try {
  await paygentContext({ userId: "user_123" }, () =>
    openai.chat.completions.create({
      model: "gpt-4o",
      messages: [{ role: "user", content: query }],
    }),
  );
} catch (err) {
  if (err instanceof PaygentLimitExceeded) {
    const guard = err.guardResult;
    if (guard.gateReason === "total_spend") {
      return "You've reached your monthly limit. Upgrade your plan to continue.";
    }
    if (guard.gateReason?.startsWith("model_limit:")) {
      const model = guard.gateReason.split(":")[1];
      return `You've used all your ${model} tokens for this period.`;
    }
    return "Usage limit reached.";
  }
  throw err;
}

Soft launch: let hard-gated calls through (Python)

In Python, set raise_on_hard_gate=False during a soft launch to fire the callback and log the event without stopping the call:

pg = Paygent.init(api_key="pg_live_...", raise_on_hard_gate=False)

The on_hard_gate callback still fires and the gate event is recorded in the audit trail; the LLM call runs anyway. Useful for measuring how often limits would fire before enforcing them.

TypeScript always enforces

The JS SDK has no raiseOnHardGate toggle — a hard gate always throws PaygentLimitExceeded. To measure-without-enforcing in JS, call pg.precheck() before the call and act on the returned GuardResult instead of letting the call run.

Pre-flight checks

Check whether the next call is allowed before making it — useful for showing remaining budget in a UI, deciding which model to use, or routing to a cheaper fallback.

Run the guard without making a call

Returns the full GuardResult for a user and optional model. In Python this is pg.check_guard(); in TypeScript it's pg.precheck() (async — it fires the soft/hard callbacks and emits the audit event, but never throws and makes no LLM call).

guard = pg.check_guard("user_123", model="gpt-4o")

if guard.status == "hard_gate":
    print(f"Blocked: {guard.message}")
elif guard.status == "soft_gate":
    print(f"Warning: {guard.message} ({guard.usage_pct:.0%} used)")
else:
    print("OK to proceed")
const guard = await pg.precheck("user_123", "gpt-4o");

if (guard.status === "hard_gate") {
  console.log(`Blocked: ${guard.message}`);
} else if (guard.status === "soft_gate") {
  console.log(`Warning: ${guard.message} (${Math.round(guard.usagePct * 100)}% used)`);
} else {
  console.log("OK to proceed");
}

A quick boolean

if pg.is_within_limit("user_123", model="gpt-4o"):
    response = openai.chat.completions.create(...)
else:
    return "Your GPT-4o tokens are used up for this period."
// The JS SDK has no isWithinLimit() — derive it from precheck():
const guard = await pg.precheck("user_123", "gpt-4o");
if (guard.status !== "hard_gate") {
  const response = await openai.chat.completions.create({ /* ... */ });
} else {
  return "Your GPT-4o tokens are used up for this period.";
}

Recommend a safe max_tokens

Bounded by whatever limits remain — especially useful for streaming or long-form generation where you want to avoid cutting off mid-response. pg.get_max_tokens() (Python) / pg.getMaxTokens() (TypeScript, async):

advice = pg.get_max_tokens(
    "user_123",
    model="gpt-4o-mini",
    messages=my_messages,  # Paygent estimates input tokens from this
)

if advice.max_tokens == 0:
    return f"Budget exhausted: {advice.binding_limit}"

response = openai.chat.completions.create(
    model="gpt-4o-mini",
    messages=my_messages,
    max_tokens=advice.max_tokens,  # never pushes the user past any limit
)
const advice = await pg.getMaxTokens("user_123", "gpt-4o-mini", {
  messages: myMessages, // Paygent estimates input tokens from this
});

if (advice.maxTokens === 0) {
  return `Budget exhausted: ${advice.bindingLimit}`;
}

const response = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: myMessages,
  max_tokens: advice.maxTokens, // never pushes the user past any limit
});

Tip

Pre-flight checks read from the same in-memory cache as the in-call guard. They are fast (no network round-trip) and reflect the most recent metered usage. Use them freely — they add negligible overhead.