Three gates layered together, all in RateLimitConfig:
- Concurrency semaphore (
max_concurrent_requests) — caps in-flight HTTP requests to the LLM. - RPM token bucket — refills at
requests_per_minute / 60per second. HonorsRetry-Afterif the server sends one on a 429. - TPM token bucket (optional) — same shape but token-sized. The default estimator is
chars / 4; pass a real tokenizer viaLLMClient(config, token_estimator=fn)for accuracy. After each response, the bucket is trued up with the response's reportedusage.total_tokensso drift doesn't accumulate.
A request must acquire all configured gates before being issued.
The token bucket starts full. If you set RPM 1000, the first minute can burst up to ~2000 requests (the bucket's 1000-token capacity plus the first minute of refill). This matches how OpenAI/Anthropic actually behave. For steady-state behavior, read the sliding-60s window median RPM, not the overall RPM.
URLs in Message.image_urls are fetched in parallel by a shared ImageFetcher:
- Concurrent, bounded by
ImageConfig.max_concurrent_downloads. - Cached in an in-memory LRU keyed by URL — duplicate URLs across a batch resolve once.
- Retried independently of the LLM call (
ImageConfig.retry). - Big payloads (>1 MiB by default) are base64-encoded on a thread to avoid blocking the event loop.
Failure behavior is configurable:
image:
on_failure: raise # raise ImageFetchError up to the batch result (default)
# on_failure: skip # drop the failed image, send the rest of the message
# on_failure: placeholder # substitute a 1x1 transparent PNGTuning rule: the fetcher runs before the LLM rate limiter. If it can't feed the limiter fast enough, your configured RPM becomes unreachable. Make sure:
max_concurrent_downloads ≥ ⌈ RPM/60 × avg_images_per_item × avg_fetch_latency_seconds ⌉The benchmark below demonstrates this empirically.
benchmarks/rpm_throughput.py verifies that the configured RPM is actually enforced end-to-end, with realistic image fetching in the loop.
# Validate RPM enforcement at 1000 RPM, 10K items, ~10 min
uv run python benchmarks/rpm_throughput.py
# Smaller sanity run
uv run python benchmarks/rpm_throughput.py --items 500
# Stress the image fetcher with realistic CDN latency, no cache
uv run python benchmarks/rpm_throughput.py --no-cache --image-latency 0.1
# Force the image fetcher to be the bottleneck
uv run python benchmarks/rpm_throughput.py --no-cache --image-latency 0.1 --max-image-downloads 4Reference result for 10K items at 1000 RPM with a 3s/request simulated LLM latency:
| Scenario | Steady-state RPM | Wall clock |
|---|---|---|
| Cached (20 unique URLs reused) | 1001.0 | 543s |
| No cache, 100ms image latency, 64 fetch slots | 1001.0 | 543s |
| No cache, 100ms image latency, 4 fetch slots | 863.0 | 697s |
The first two are statistically identical — at sensible defaults, image traffic doesn't move the LLM RPM. The third shows what happens when the fetcher becomes the bottleneck: items queue upstream of the rate limiter, and effective RPM drops below the configured cap.
benchmarks/embed_throughput.py demonstrates the embeddings analogue: throughput is governed by how many inputs the packer puts in each request, not by RPM alone.
# Packed (default): a few fat requests, RPM never binds
uv run python benchmarks/embed_throughput.py
# The pathological un-packed case: one request per input → RPM-bound
uv run python benchmarks/embed_throughput.py --items 3500 --max-inputs 1 --rpm 2000 --latency 0.01
# Force the token cap (not the count cap) to bind the chunk size
uv run python benchmarks/embed_throughput.py --max-tokens 2000Reference result for 20K inputs (~60 tokens each), 20ms/request latency:
| Scenario | inputs/request | inputs/sec | requests |
|---|---|---|---|
Packed (max_inputs_per_request 2048) |
2048 | 9107 | 10 |
Un-packed (max_inputs_per_request 1)* |
1 | 78 | 3500 |
* un-packed row run at 3500 inputs / RPM 2000 / 10ms latency so it finishes; it's RPM-bound, the packed row is not. The ~100× gap is the entire point of the embeddings micro-batching design — max_inputs_per_request is the dominant throughput lever for embeddings.