Rate-Limited LLM Request Scheduler
Build the scheduler that sits in front of an LLM provider. Requests carry a tenant, model, estimated tokens and a priority lane. Dispatch must respect a provider rate limit, per-tenant token quotas, priority with starvation protection, and track cost per tenant.
1Rate limiter
submit(request) queues work; dispatch(now) returns the requests that may start, using a token bucket (rate per second, burst capacity). Time is passed in, never read from the wall clock.
2Per-tenant quotas
Each tenant has a token quota per fixed window. Reserve est_tokens at dispatch; skip (do not block on) requests whose tenant is over quota; reject at submit any request that could never fit. complete(id, input, output) reconciles the reservation with actual usage.
3Priority lanes
Lane 0 (interactive) is served before lane 1 (batch). A lower lane passed over starvation_limit times in a row while non-empty gets the next slot.
4Cost accounting
complete() prices actual input/output tokens per model in integer micro-USD and adds it to a per-tenant ledger, returning the request's cost.
Separate the three gates: the bucket decides whether anything may start, quotas decide which tenant may, and lane order decides who goes first. Reserve the estimate at dispatch and reconcile at completion, or one tenant can overrun by issuing many requests before any finish. Keep money in integers.
Try it first.
tests.py · 12 passing
prices = {"small": (1, 4), "large": (3, 15)}
ids = lambda rs: [r.id for r in rs]
# stage 1: rate limit (1 req/s, burst 2)
s = Scheduler(1.0, 2, {"a": 10_000}, 60, prices)
for i in range(3):
s.submit(Request(f"r{i}", "a", "small", 10))
assert ids(s.dispatch(0.0)) == ["r0", "r1"]
assert s.dispatch(0.5) == []
assert ids(s.dispatch(1.0)) == ["r2"]
# stage 2: quotas; over-quota tenant does not block others in the same lane
s = Scheduler(100.0, 100, {"a": 100, "b": 100}, 60, prices)
s.submit(Request("a1", "a", "small", 60)); s.submit(Request("a2", "a", "small", 60))
s.submit(Request("b1", "b", "small", 60))
assert ids(s.dispatch(0.0)) == ["a1", "b1"]
s.complete("a1", 10, 10) # actual 20 < reserved 60: refund 40
assert ids(s.dispatch(0.1)) == ["a2"]
s.submit(Request("a3", "a", "small", 60))
assert s.dispatch(1.0) == [] # a has 80 used in this window
assert ids(s.dispatch(60.0)) == ["a3"] # new window
try:
s.submit(Request("big", "a", "small", 101)); assert False
except ValueError:
pass
# stage 3: priority lanes with starvation guard (limit 2)
s = Scheduler(1.0, 1, {"a": 10**6}, 60, prices, lanes=2, starvation_limit=2)
for i in range(4):
s.submit(Request(f"hi{i}", "a", "small", 1, lane=0))
s.submit(Request("lo0", "a", "small", 1, lane=1))
order = [ids(s.dispatch(float(t)))[0] for t in range(5)]
assert order == ["hi0", "hi1", "lo0", "hi2", "hi3"]
# stage 4: cost accounting in integer micro-USD
s = Scheduler(10.0, 10, {"a": 10**6, "b": 10**6}, 60, prices)
s.submit(Request("x", "a", "large", 2000)); s.submit(Request("y", "b", "small", 2000))
s.submit(Request("z", "a", "small", 2000))
s.dispatch(0.0)
assert s.complete("x", 1000, 200) == 6000
assert s.complete("y", 500, 500) == 2500
assert s.complete("z", 100, 0) == 100
assert s.cost == {"a": 6100, "b": 2500}How does this scale past one process? Move the bucket and quota counters to Redis with atomic Lua scripts (or a central admission service), honour provider 429 Retry-After by shrinking the bucket rate, and replace FIFO scanning with per-tenant sub-queues and weighted fair queuing so a noisy tenant can't fill a lane.