Enforces per-session budgets on tokens, LLM/inference calls, and wall-clock
duration. max_calls counts inference calls only — MCP tool calls, A2A
messages, and other outbound traffic do not count toward it.
Supports three on_exceed modes:
deny— return 403 (default)observe— shadow mode: log without blocking, useful for calibrating limitspause— HITL: POST to a webhook for approval before continuing
A "session" is the AuthBridge session ID (typically one A2A conversation or agent task invocation). Redis holds durable counters across pods; a local cache serves the hot path with zero I/O.
Opt-in — build with -tags include_plugin_sessionbudget:
docker build -f cmd/cortex/Dockerfile \
--build-arg GO_BUILD_TAGS="include_plugin_sessionbudget" \
-t authbridge:latest .Same tag works for cmd/cortex-envoy/Dockerfile.
pipeline:
outbound:
plugins:
- name: token-exchange
config: { ... }
- name: session-budget
config:
redis_url: "redis://valkey.infra.svc:6379"
max_tokens: 50000
max_calls: 100
max_duration_seconds: 1800
- name: inference-parser| Field | Default | Description |
|---|---|---|
redis_url |
— (required) | Redis/Valkey URL |
max_tokens |
0 | Cumulative token ceiling per session (all kinds summed). 0 = no limit. |
max_input_tokens |
0 | Per-kind ceiling on uncached prompt tokens. 0 = no limit. |
max_cache_read_tokens |
0 | Per-kind ceiling on prompt tokens served from cache. 0 = no limit. |
max_cache_write_tokens |
0 | Per-kind ceiling on prompt tokens written to cache. 0 = no limit. |
max_output_tokens |
0 | Per-kind ceiling on generated completion tokens. 0 = no limit. |
max_reasoning_tokens |
0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. See note below — this limit was inert on Anthropic traffic until recently. |
max_calls |
0 | LLM/inference call cap (from inference-parser); MCP, A2A, and other outbound traffic do not count. 0 = no limit. See note below on enforcement scope. |
max_duration_seconds |
0 | Session lifetime cap (0 = no limit) |
on_exceed |
deny |
deny (403), observe (log only), or pause (webhook) |
pause_webhook |
— | URL to POST on breach (required when on_exceed=pause) |
pause_timeout |
30s |
Max wait for webhook response |
pause_timeout_action |
deny |
Fallback on timeout/error: deny or allow |
pause_grace_period |
5m |
Suppress repeat webhooks after approval. 0s disables the grace window (webhook fires on every breach). |
session_ttl_seconds |
7200 | Redis key TTL. Must be ≥ max_duration_seconds when the latter is set (rejected at Configure time otherwise). |
refresh_interval |
5s |
Local-cache sync interval |
redis_unavailable |
fail_open |
Only fail_open supported today |
default_session_fallback |
false |
Pool sessionless traffic into a shared "default" bucket. Single-workload only — one caller exhausting the budget denies the rest. Under max_duration_seconds, continuous traffic refreshes the TTL, so once elapsed exceeds the limit requests stay denied until the key expires or is deleted. |
At least one of max_tokens, max_input_tokens, max_cache_read_tokens, max_cache_write_tokens, max_output_tokens, max_reasoning_tokens, max_calls, max_duration_seconds must be > 0.
max_reasoning_tokens was inert on Anthropic traffic, and is not any more.
inference-parser did not read Anthropic's
usage.output_tokens_details.thinking_tokens, so ReasoningTokens was always 0 on
that path and this limit could never be reached however low it was set. The parser
reads it now, which makes the counter real without any config change of yours.
If you set max_reasoning_tokens against Claude traffic and saw no effect, that was
why — and with on_exceed at its deny default those sessions will now start
receiving 403s once they cross it. Audit any value currently set before upgrading.
OpenAI-format endpoints were never affected: that path has always read
completion_tokens_details.reasoning_tokens.
max_calls enforcement scope. Only inference calls surfaced by
inference-parser increment the counter, but the limit check runs on
every outbound request. Once the LLM counter crosses max_calls, the
next outbound of any kind (MCP tool call, A2A message, etc.) is the one
that gets the 403.
Pipeline position: must appear before inference-parser on the outbound
pipeline. Both must be present for token counting (inference-parser supplies the
token counts session-budget accumulates).
Returns 403 with a JSON body:
{
"error": "budget.exceeded",
"message": "token limit reached: 50200/50000",
"details": {
"spent_tokens": 50200,
"spent_calls": 42,
"token_limit": 50000,
"call_limit": 100,
"duration_seconds": 1205,
"duration_limit": 1800
}
}duration_seconds / duration_limit are included only when
max_duration_seconds is set.
Counters still accumulate and limits are still evaluated, but breaches only emit
a WARN log ("budget exceeded (shadow mode)") and the request continues. Use to
calibrate limits before enforcing:
- Deploy with
on_exceed: observeand conservative limits. - Watch logs for shadow-mode entries.
- Adjust
max_tokens/max_calls/max_duration_secondsto fit real workloads. - Flip to
on_exceed: deny(orpause) once confident.
Call accounting is response-driven — max_calls increments on
response completion, not request entry. Cache-at-limit rejects
reliably, but concurrent requests near the limit can overshoot by up
to the in-flight count before responses catch up. Treat max_calls
as best-effort under bursty concurrency.
On breach, POST to pause_webhook and block the request until the webhook
responds or pause_timeout fires.
The webhook is any HTTP endpoint you build that speaks the contract
below — an in-cluster Service, a workflow entrypoint (Temporal, Argo,
Slack middleware), or a local stub. It must be reachable from the
AuthBridge pod and respond within pause_timeout (default 30s), or
the plugin falls back to pause_timeout_action.
Contract:
| Aspect | Requirement |
|---|---|
| Method | POST |
| URL | Exactly the pause_webhook value (no path templating) |
Request Content-Type |
application/json |
| Request body | See below — always the same schema |
| Success response | HTTP 200 with application/json body containing an action field |
| Response body cap | 4 KiB (larger responses are truncated at decode) |
| Latency budget | Must respond within pause_timeout; slow webhooks block the caller |
| Auth | None injected by the plugin — add your own (mTLS, network policy, IP allowlist) at the transport layer |
| Retries | None — the plugin calls once per breach |
Request body:
{
"session_id": "abc-123",
"reason": "call limit reached: 50/50",
"spent_tokens": 48200,
"spent_calls": 50,
"token_limit": 100000,
"call_limit": 50,
"duration_seconds": 1205,
"duration_limit": 1800
}Expected response:
{"action": "approve"}or
{"action": "deny", "reason": "operator rejected"}On approval: the request continues; subsequent requests from the
same session skip the webhook for pause_grace_period (default 5m,
pod-local — each pod fires one webhook before its own grace kicks in).
Concurrent breaches during an in-flight webhook wait on the pending
call and honor its outcome — all approved together, or all denied
together.
On timeout / non-200 / bad JSON / unreachable: falls back to
pause_timeout_action (deny returns 403; allow continues). Client
disconnect mid-wait does not trigger this fallback — the webhook is
bounded only by pause_timeout, so its verdict still lands for any
followers on the same flight. If your webhook can be unhealthy and
pause_timeout_action: deny, an outage turns budget breaches into hard
403s.
If a human is in the loop, bump pause_timeout to minutes so the
request can wait for a real approval decision.
| Scenario | Behavior |
|---|---|
| Redis down at startup | Fail-open until refresh populates cache |
| Redis fails mid-session | Local cache keeps enforcing; writes dropped |
Pod restart, pause |
Request #1 hydrates from Redis synchronously |
Pod restart, deny / observe |
Request #1 skips (cold_cache); see below |
Hydrate timeout in pause (Redis p99 > 200ms) |
Logs WARN, falls through to cold_cache without populating counters; every request retries the hydrate and bypasses the webhook until Redis responds within the deadline |
| Webhook unreachable | Falls back to pause_timeout_action |
When a request arrives with no local cache entry for the session:
pause— hydrates from Redis synchronously, so an over-budget session fires the webhook on request #1.deny/observe— skip withreason=cold_cacheand continue. Counters populate as inference responses stream back and via the background refresh loop. A pre-existing over-budget session may pass up to one request per pod before enforcement resumes. Keeps Redis off the hot path.
Redis unavailability degrades enforcement to local-cache-only rather than blocking requests.
Token counting requires usage in provider responses. Providers that omit
usage from streaming chunks (e.g. Anthropic via LiteLLM) will show
promptTokens=0 in inference-parser logs — max_tokens enforcement won't
trigger, but max_calls and max_duration_seconds still apply. Ollama,
OpenAI, and Azure OpenAI include usage in streaming responses and work fully.
session-budget:<session-id> (Hash, TTL = session_ttl_seconds)
tokens cumulative token count
calls inference call count
started_at first-call unix timestamp
Redis / Valkey:
docker run -d --name valkey -p 6379:6379 valkey/valkey:latest
# redis_url: redis://localhost:6379 (or host.docker.internal from a container)Pause-mode webhook stub. A one-liner that returns approve for every
request — enough to smoke-test on_exceed: pause end-to-end:
docker run -d --name pause-webhook -p 8888:8888 \
python:3.12-alpine@sha256:d09d15e60962ca365d1cd544a48773bac9d33f2fb1b00f2aa0deec78ade7dc31 \
python -c "import http.server,json; \
h=type('H',(http.server.BaseHTTPRequestHandler,),{ \
'do_POST':lambda s:(s.send_response(200), \
s.send_header('Content-Type','application/json'),s.end_headers(), \
s.wfile.write(b'{\"action\":\"approve\"}'))}); \
http.server.HTTPServer(('',8888),h).serve_forever()"
# pause_webhook: http://localhost:8888 (or http://host.docker.internal:8888)Swap approve for deny to test the reject path. Logs land in
docker logs pause-webhook.
If your namespace runs Istio ambient mesh (label
istio.io/dataplane-mode: ambient), the Valkey pod and any plain-HTTP
pause webhook need to opt out with the pod-level label
istio.io/dataplane-mode: none. Ambient's ztunnel only accepts HBONE
(HTTP/2 CONNECT), and Redis RESP is raw TCP — the connection is closed
before reaching Valkey. Symptom: session-budget action=skip reason=cold_cache on every request even though the Redis key exists.
For an in-cluster stub, apply
demos/session-budget/k8s/pause-webhook-stub.yaml and set
pause_webhook: http://pause-webhook-stub.team1.svc.cluster.local. See
../demos/session-budget/README.md.
Run the plugin tests:
cd core
go test ./plugins/sessionbudget/... -v -count=1