Usage, quotas, rate limits
Overview
Make consumption measurable and boundable. Record one usage_records row per model call (including cache tokens and the call purpose, so summarizer and subagent cost are separable), price it from a config-driven table into integer micro-cents, enforce per-tenant monthly quotas checked before a run and accounted post-hoc, and rate-limit per API key with an in-process token bucket plus a TTL sweeper. Feed the priced cost into Langfuse cost_details so cost is visible next to the trace that produced it.
Requirements
Functional
- Dependencies are `[9, 10, 12]`: the `subagent` purpose comes from phase 9, the `summarize` purpose and the compaction path from phase 10, and the second provider's models and capabilities from phase 12.
usage_records: one row per provider call with tenant, agent, session, run, turn, provider, model, purpose (main|summarize|subagent), input/output/cache-read/reasoning tokens, cache-write split into 5m and 1h buckets, cost in micro-cents, andcreated_at.- Pricing table from config: per provider+model input, output, cache-read, cache-write-5m and cache-write-1h prices per million tokens (Anthropic prices the two TTLs differently; collapsing them silently mis-prices every cached run), with an explicit
unknown modelbehaviour (record tokens, cost zero, log once, increment a counter). - Cost is
bigintmicro-cents; no floating point anywhere in the money path. tenant_quotas: monthly token and cost caps plusmax_concurrent_runs. Checked at run creation: over quota returns 402 with the current usage; a run already started is never killed mid-turn by quota.- Usage aggregation endpoints:
GET /v1/usage?from=&to=&group_by=agent|model|purpose|day(tenant-scoped) andGET /v1/admin/usage?tenant_id=for the platform principal. - Rate limiting: per API key
x/time/ratelimiter with configurable rate and burst, a sharded map with a TTL sweeper evicting idle entries, and 429 responses carryingRetry-AfterandX-RateLimit-*headers. - Concurrency limit: a tenant may not exceed
max_concurrent_runsactive runs; excess run creation returns 429 with a clear reason distinct from the rate-limit 429.
Non-functional
- Usage recording is on the run’s critical path but must never fail a run: write failures are logged, counted and retried once, then dropped.
- Quota checks are a single indexed aggregate query over the current month, cached per tenant for
HANGO_QUOTA_CACHE_TTL(default 10s). - The in-process limiter is a documented single-replica limitation; the learning doc states exactly what breaks at two replicas and what replaces it.
- Cross-tenant isolation tests for
usage_recordsandtenant_quotas.
Architecture
// internal/usage/record.go
type Purpose string // "main" | "summarize" | "subagent"
type Record struct {
TenantID, AgentID, SessionID, RunID uuid.UUID
Turn int
Provider string
Model string
Purpose Purpose
// Red team: F13 — cache writes are priced per TTL bucket.
Input, Output, CacheRead, CacheWrite5m, CacheWrite1h, Reasoning int
CostMicroCents int64
}
// internal/usage/pricing.go
type Price struct { // micro-cents per million tokens. Red team: F13
InputPerMTok, OutputPerMTok, CacheReadPerMTok int64
CacheWrite5mPerMTok, CacheWrite1hPerMTok int64
}
type Pricing struct{ table map[string]Price } // key "provider/model"
func (p *Pricing) Cost(r Record) (int64, bool) // false = unknown model
// internal/usage/quota.go
type Quota struct {
MonthlyTokens, MonthlyCostMicroCents int64
MaxConcurrentRuns int
}
type Checker struct{} // cached per-tenant month aggregate
func (c *Checker) Allow(ctx context.Context) (Decision, error) // called at run creation
type Decision struct {
Allowed bool
Reason string // "monthly_tokens" | "monthly_cost" | "max_concurrent_runs"
Used, Limit int64
}
// internal/server/ratelimit.go
type Limiter struct{} // sharded map[keyID]*entry{limiter, lastSeen}; TTL sweeper via safego
func (l *Limiter) Allow(keyID uuid.UUID) (ok bool, retryAfter time.Duration, remaining int)-- migrations/00009_usage_and_quotas.sql
create table usage_records (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
agent_id uuid not null references agents(id) on delete cascade,
session_id uuid not null references sessions(id) on delete cascade,
run_id uuid not null references runs(id) on delete cascade,
turn int not null default 0,
provider text not null,
model text not null,
purpose text not null check (purpose in ('main','summarize','subagent')),
input_tokens int not null default 0,
output_tokens int not null default 0,
cache_read_tokens int not null default 0,
cache_write_5m_tokens int not null default 0, -- Red team: F13
cache_write_1h_tokens int not null default 0, -- Red team: F13
reasoning_tokens int not null default 0,
cost_micro_cents bigint not null default 0,
created_at timestamptz not null default now()
);
create index usage_records_tenant_created_idx on usage_records (tenant_id, created_at desc);
create index usage_records_tenant_run_idx on usage_records (tenant_id, run_id);
create table tenant_quotas (
tenant_id uuid primary key references tenants(id) on delete cascade,
monthly_tokens bigint not null default 0, -- 0 = unlimited
monthly_cost_micro_cents bigint not null default 0, -- 0 = unlimited
max_concurrent_runs int not null default 4,
rate_per_minute int not null default 60,
burst int not null default 20,
updated_at timestamptz not null default now()
);Instrumentation
- The
chatspan gainshango.cost.micro_centsandhango.usage.purpose;langfuse.observation.cost_detailsis set from the priced record (explicit JSON, since Langfuse’s inference of semconv cache-token keys is unverified) andlangfuse.observation.usage_detailsis reconciled with the same numbers. - The run’s root
invoke_agentspan gains totals:hango.cost.micro_cents,hango.usage.input_tokens,hango.usage.output_tokens, andlangfuse.trace.metadata.cost_micro_cents. - Spans:
usage.record(INTERNAL, per write) andquota.check(INTERNAL, withhango.quota.reason,hango.quota.used,hango.quota.limit,hango.quota.cache_hit). - Metrics:
hango.usage.tokenscounter (attributesgen_ai.request.model,gen_ai.token.type,purpose),hango.usage.costcounter in micro-cents (attributesmodel,purpose),hango.quota.rejectionscounter (attributereason),hango.ratelimit.rejectionscounter,hango.ratelimit.tracked_keysgauge,hango.usage.record_failurescounter,hango.pricing.unknown_modelcounter (attributemodel). - Log fields: quota and rate-limit rejections log at warn with tenant id, reason, used and limit — the two events users will ask about most.
Related Code Files
Create
internal/usage/record.go,internal/usage/pricing.go,internal/usage/pricing_test.go,internal/usage/quota.go,internal/usage/quota_test.go,internal/usage/recorder.go(the write path with retry and metrics).internal/server/ratelimit.go,internal/server/ratelimit_test.go(usingtesting/synctestfor deterministic bucket timing).internal/store/pg/usage.go,internal/store/pg/quota.go,internal/store/pg/usage_test.go.internal/server/handlers_usage.go,internal/server/handlers_quotas.go(platform principal sets quotas).migrations/00009_usage_and_quotas.sql.docs/learning/13-usage-quotas-rate-limits.md.
Modify
internal/agent/loop.go— after each model call, build ausage.Recordwith the turn and purpose and hand it to the recorder; add the cost attributes to the span.internal/agent/mwsummarize.go,internal/agent/mwsubagent.go— tag their provider calls with the rightPurposevia context.internal/server/handlers_runs.go— quota and concurrency check before insert; 402 and 429 paths.internal/server/auth_middleware.go— apply the per-key limiter after authentication, with per-tenant rate/burst fromtenant_quotas.internal/app/app.go— build pricing from config, the limiter with its sweeper, the quota checker.internal/config/config.go—HANGO_PRICING(JSON table),HANGO_QUOTA_CACHE_TTL,HANGO_RATELIMIT_DEFAULT_RPM,HANGO_RATELIMIT_DEFAULT_BURST,HANGO_RATELIMIT_ENTRY_TTL.internal/store/pg/tenancy_test.go— addusage_records,tenant_quotas.docs/system-architecture.md,docs/learning/README.md.
Implementation Steps
- Write
migrations/00009_usage_and_quotas.sqland the scoped usage and quota stores, including the month-aggregate query (sum(input+output) tokens,sum(cost)), the per-run aggregate, and the group-by variants for the reporting endpoint. - Write
internal/usage/pricing.go: parseHANGO_PRICINGas{"anthropic/claude-x": {"input": 300, "output": 1500, "cache_read": 30, "cache_write_5m": 375, "cache_write_1h": 600}}in micro-cents per million tokens ;Costcomputes with integer math only (tokens * pricePerMTok / 1_000_000, rounding half-up); unknown models returnfalse. - Write
internal/usage/recorder.go:Record(ctx, r)prices the record, writes it, sets the span attributes and metrics; on write failure it logs, incrementshango.usage.record_failures, retries once, then drops without failing the run. - Thread
Purposethrough context so the summarizer and subagent child loops record their own purpose without the loop needing to know who called it. - Write
internal/usage/quota.go:Checkercaching the month aggregate per tenant for the TTL, plus an active-run count query for concurrency;Allowreturns aDecisionnaming the binding limit. - Wire the check into run creation:
Decision.Allowed == falsereturns 402 for quota reasons and 429 for concurrency, both with a JSON body carrying reason, used and limit. - Write
internal/server/ratelimit.go:Nshards keyed by hashed key id, each a mutex-guardedmap[uuid.UUID]*entry;Allowuseslimiter.Reserve()to deriveRetry-After; asafegosweeper evicts entries idle beyond the TTL and updateshango.ratelimit.tracked_keys. Per-tenant rate and burst are read fromtenant_quotaswith a short cache. - Apply the limiter in the auth middleware after principal resolution (so limits are per key, not per IP) and set
X-RateLimit-Limit,X-RateLimit-RemainingandRetry-Afterheaders. This is in addition to, not a replacement for, the phase-2 pre-authentication limiter, which protects the unauthenticated path. - Write the usage reporting handlers and the platform quota-setting handler. The quota-setting handler calls
SetAuditAction(c, "quota.update", "tenant_quota", tenantID)and records the old and new limits indetail, so a tenant that suddenly stops being rate-limited has a named cause. - Add
langfuse.observation.cost_detailsand the run-level cost attributes. - Tests: pricing integer math including rounding and cache tokens; unknown model records tokens with zero cost and increments the counter; per-run usage aggregate matches the sum of records (integration); quota rejection at run creation with the right status and reason; a started run is not killed when it crosses the quota mid-run (test asserts completion); concurrency limit; rate limiter under
testing/synctestasserting burst, refill andRetry-After; sweeper eviction;usage_recordsandtenant_quotascross-tenant cases. - Update docs and write the learning doc.
Learning Doc Outline
docs/learning/13-usage-quotas-rate-limits.md must contain:
- Concept: why LLM quotas are inherently post-hoc (token counts exist only after the call) and what that means for enforcement design.
- Design choices: one record per model call with a purpose tag, integer micro-cents, config-driven pricing, pre-run check with post-hoc accounting, per-key token bucket with TTL sweep, distinct 402 and 429 semantics.
- Trade-offs: reserve-then-reconcile (goclaw’s shape) vs check-then-record (hango’s); in-process limiter vs Postgres counter vs Redis; per-key vs per-tenant vs per-IP limiting; caching the quota aggregate vs querying every run.
- The money-path rule: never floats, always integers, and why
bigintmicro-cents in both the DB and Go is the safe combination. - Single-replica limitation, stated precisely: two replicas double the effective rate limit and can both admit a run at the quota boundary; the replacement is a Postgres counter with
INSERT ... ON CONFLICTor a shared store. - goclaw comparison:
internal/usagepricing catalog withUsageReserveRequest→UsageReservationResult→UsageReconcileRequest, i.e. reserve-then-reconcile with a pre-flight token estimate, requiring a token counter on the critical path. - deepagents comparison: no usage, cost or quota concept at all — this layer is purely a server concern, which is why it is not in the harness research.
- How to debug this layer:
hango.usage.costandhango.usage.tokenssplit bypurposeanswers “why is this tenant expensive” (often summarizer or subagent calls, which is exactly why purpose is a dimension);quota.checkspans withused/limitexplain a 402 to a user in one screenshot;hango.ratelimit.rejectionsversushango.quota.rejectionsdistinguishes the two 429-ish failures; in Langfuse,cost_detailsper observation attributes spend to the exact generation. - Why cache-write pricing needs TTL buckets, and what a single collapsed field does to a monthly invoice reconciliation.
- Exercises: set a tiny quota and hit it; set a 1-rpm limit and read the headers; price an unknown model and watch the counter.
- Time budget: 9h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass. - Pricing tests cover integer rounding, unknown models, and the two cache-write TTL buckets priced at their own rates.
-
GET /v1/usage?group_by=purposefor a run with a summarization and a subagent call shows three distinct purposes (integration test). - Over-quota run creation returns 402 with reason, used and limit; a run already in flight completes normally (tests).
- Concurrency limit returns 429 with a reason distinct from the rate-limit 429 (test).
- Rate limiter test under
testing/synctestasserts burst, refill rate andRetry-After; the sweeper evicts idle keys. -
usage_recordsandtenant_quotasare in the cross-tenant suite. - Setting a quota writes an
audit_eventsrow with actionquota.updatecarrying the previous and new limits indetail. - In Grafana,
hango.usage.costandhango.usage.tokensseries exist split by model and purpose; in Langfuse, the generation showscost_detailsmatching the recorded micro-cents.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Usage write failure loses billing data | Medium x High | hango.usage.record_failures non-zero | Retry once, log with the full record, count it; the metric is alerted in phase 14, and the run is never failed for a usage write |
| Pricing table drifts from provider prices | High x Medium | Cost reports diverge from invoices | Pricing is config, not code; the deployment guide documents updating it, and hango.pricing.unknown_model catches new model names immediately |
| Quota cache lets a tenant overshoot | Medium x Low | Usage slightly above the cap | 10s TTL bounds the overshoot to concurrent in-flight runs; documented as accepted behaviour |
| In-process limiter wrong at multi-replica | Certain x Low | Only at multi-replica, a non-goal | Documented precisely in the learning doc and docs/system-architecture.md; the follow-up is in plan.md |
| Float creeps into the money path | Medium x High | Cost mismatches in aggregates | bigint micro-cents end to end; a lint-style test greps internal/usage for float |
| Rate limiting applied before auth would limit by IP | Low x Medium | Shared-NAT tenants throttled together | Limiter runs after principal resolution; asserted by middleware order test |
Rollback
goose down one step drops usage_records and tenant_quotas. Disabling the quota checker and limiter in internal/app (both are single constructor calls) returns the server to phase 12 behaviour; usage recording is additive and can be left on independently of enforcement.