Docs · CEL policy

One engine, type-checked at config-load.

Routing, rate-limits, quotas, redaction, and access control all speak the same language: CEL. Policies compile and type-check at config-load, not at first request. Invalid policies fail loud on boot.

Available variables

VariableTypeNotes
requestRequestmodel, messages, headers, backend, path, size, stream
tenantTenantid, plan, region, flags
userUserid, email, role (from auth header)
quotaQuotaremaining, limit, resets_at
receiptReceiptid, tokens_in, tokens_out, cost, annotations
timeTimestampcurrent time (seconds since epoch)

Built-in functions

  • allow(), deny(reason): pre-request disposition.
  • annotate(key, value): attach metadata to the receipt.
  • route(upstream_id): pick the upstream for this request.
  • rate_limit(key, per_minute, per_hour): enforce a rate cap, keyed arbitrarily.
  • quota_spend(amount): deduct from the quota ledger.
  • redact_field(path, pattern): mutate a specific field.
  • challenge_402(price, currency): issue an x402 payment challenge.

Operator semantics

Standard CEL rules apply: short-circuit && and ||, ternary ?:, membership with in, string methods like startsWith and contains. No loops. No I/O. Expressions are pure; side effects come from the built-in functions above.

Type checking at load

When you run relaygate config apply, every policy is compiled and type-checked. Type errors fail the load. The error message points at the file, line, and token:

error: type mismatch in policy 'route-by-plan'
  /etc/relaygate/policies/route.cel:4:18
  tenant.plan == 'pro' && request.modl == 'gpt-5'
                                 ^^^^
  no such field 'modl' on type Request
  did you mean 'model'?

Examples

1. Simple deny

tenant.plan == "free" && request.model == "gpt-5"
  ? deny("gpt-5 is not on the free plan")
  : allow()

2. Route by tenant plan

tenant.plan == "pro" ? route("anthropic-direct") : route("openai")

3. Per-tenant rate-limit

rate_limit(
  key = "tenant:" + tenant.id,
  per_minute = tenant.plan == "pro" ? 1200 : 60
)

4. Quota enforcement

quota.remaining <= 0
  ? deny("quota exhausted; resets at " + string(quota.resets_at))
  : quota_spend(1)

5. Content-based routing

request.messages.size() > 0
  && request.messages[request.messages.size() - 1].content.contains("code:")
  ? route("anthropic-direct")
  : route("openai")

6. Time-window policy

// between 22:00 and 06:00 UTC, route to the cheaper backend
time.getHours() >= 22 || time.getHours() < 6
  ? route("openrouter")
  : route("openai")

7. Role-based redaction

user.role != "admin"
  ? redact_field("request.messages[*].content", "\\b\\d{3}-\\d{2}-\\d{4}\\b")
  : allow()

8. x402 commerce challenge

request.path.startsWith("/v1/agents/")
  && !request.headers.exists("x-truecom-settlement")
  ? challenge_402(0.004, "USDC")
  : allow()

Next: Observability →