Skip to content

perf: pre-warm upstream realtime connection pool to cut session-establishment latency - #31163

Merged
ishaan-berri merged 24 commits into
litellm_internal_stagingfrom
litellm_rust_gateway_realtime_pool
Jun 24, 2026
Merged

ishaan-berri merged 24 commits into
litellm_internal_stagingfrom
litellm_rust_gateway_realtime_pool

Conversation

@ishaan-berri

@ishaan-berri ishaan-berri commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Relevant issues

Latency optimization for the Rust ai-gateway realtime path. Re-benchmarked live on Render at 5000 calls / 500 concurrency, 10 gateway instances.

Pre-Submission checklist

  • I have added meaningful tests
  • My PR's scope is as isolated as possible; it only solves 1 specific problem (realtime session-establishment latency)
  • Greptile review pending

Type

🚄 Infrastructure
🆕 New Feature

Changes

A latency optimization on the Rust ai-gateway realtime path.

The problem

The gateway's only added cost over direct-to-OpenAI is session establishment: on each client connect it dials a fresh upstream WS to OpenAI and blocks until session.created. Dial, first-audio and streaming add ~0.

What this does

A pre-warmed upstream connection pool keyed by (model, api_key, api_base). A background task keeps OpenAI realtime sockets already connected and already past session.created (buffered). On a client connect we take a warm socket and relay its buffered session.created instantly via a local handoff (a Vec::pop + liveness check — sub-millisecond, no network), then splice exactly as today. On a pool miss or dead warm socket we fall back to fresh-dial — the pool is never on the critical path for correctness, only latency.

Config: REALTIME_POOL_SIZE (default 4; 0 disables → fresh-dial) and REALTIME_POOL_MAX_IDLE_SECS (default 30).

Concurrent refill (makes it land at scale)

The replenisher originally dialed missing warm sockets sequentially, so a full refill cost needed × handshake (~needed × 350 ms). Under high connect rates the pool drained faster than it refilled — at 5000/500 only ~15% of connects hit the pool, so the median connect still paid the full handshake. This PR fires the refill dials concurrently (join_all), refilling in ~one handshake window. Result: pool hit rate went 15% → ~76% and median session 357 ms → 0 ms.

Replenisher safety (review follow-ups)

  • Backoff on failing keys. A key whose warm-up dials all fail (invalid credentials / unreachable upstream) is put into exponential backoff (500 ms base, doubling, 30 s cap) instead of firing needed concurrent TLS dials every 250 ms tick — bounds connection attempts so a single misconfigured deployment can't exhaust upstream rate limits and degrade valid cold-path traffic. Resets on the first successful dial; its own lock keeps the request hot path (take) contention-free.
  • MissedTickBehavior::Skip on the replenish interval, so a slow tick doesn't trigger catch-up bursts.
  • UpstreamKey Debug redacts api_key ([REDACTED]) so the credential can't leak via logs.

Benchmark results (5000 calls / 500 concurrency, 10 instances)

All times in milliseconds. Each leg run twice; values pooled across both runs. Harness: ishaan-berri/litellm-realtime-bench (ws-bench), run from an 8-vCPU Render runner against OpenAI-direct and the deployed gateway. Phases per WebSocket connection:

  • Dial — TCP + TLS + WS upgrade handshake
  • Session — upgrade done → OpenAI session.created (the phase the pool removes)
  • 1st-audio — response.create sent → first audio delta (OpenAI inference)
  • Total — full wall-clock per connection
phase / metric Direct OpenAI Gateway pool OFF Gateway pool ON
success 99.8% 99.8% 99.8%
session p50 7 367 0
session p95 18 692 767
session p99 29 847 1055
dial p50 276 85 158
1st-audio p50 440 459 664
total p50 816 952 1010
total p95 2152 1907 1970
total p99 2692 2386 2610

Direct vs Gateway (pool ON), with overhead and where the gateway is faster:

metric Direct OpenAI Gateway pool ON Overhead (ms) vs OpenAI
dial p50 (ms) 276 158 −118 faster
session p50 (ms) 7 0 −7 faster
1st-audio p50 (ms) 440 664 +224 slower¹
total p50 (ms) 816 1010 +194 slower¹
total p95 (ms) 2152 1970 −182 faster
total p99 (ms) 2692 2610 −82 faster

The gateway is faster than direct OpenAI on 4 of 6 metrics (negative overhead): dial and the total tail (p95/p99), because the runner + gateway are co-located in Render Oregon while OpenAI's edge is further, and the warm handoff makes session sub-millisecond. ¹ The two "slower" rows track a 1st-audio difference (440 → 664 ms) between the legs. 1st-audio is OpenAIs own inference time, which the gateway only relays — but the legs ran at different times, so this gap cannot yet be attributed to OpenAI-side variance vs a possible warm-socket penalty without an interleaved A/B (alternating direct/gateway connections within one run so OpenAI variance cancels). Defensible claim: session-establishment overhead is eliminated (−7 ms p50, sub-1ms warm handoff); end-to-end total is inconclusive pending that controlled run.

Gateway session overhead vs direct: pool OFF +360 ms p50 → pool ON −7 ms p50. The warm handoff is sub-millisecond, so the median connect's session phase collapses to ~0. ~76% of connects hit the pool; ~70% had session < 1 ms. The remaining ~24% miss and fresh-dial (~350 ms), which is the p95/p99 session tail — raise REALTIME_POOL_SIZE to shrink it.

Pool sizing: size to peak concurrent connects per instance, REALTIME_POOL_SIZE ≈ peak_concurrency / instances. This run used 64/instance for ~500 concurrency over 10 instances. The concurrent warm-up (≈64 dials/instance at boot) was accepted by OpenAI with no rate-limit errors.

Note: gateway dial is lower than direct and gateway total p95/p99 are near direct, because the runner + gateway are co-located in Render Oregon while OpenAI's edge is further. The gateway's only real overhead is the session phase, which the pool removes at the median.

Tests

Pool mechanics unit-tested against an in-process fake OpenAI realtime WS server: warm handoff relays the buffered session.created; pool miss falls back to fresh-dial; dead/stale warm socket is discarded; background replenisher tops up a registered key; a broken key enters backoff and is not re-dialed during the window; a healthy key never backs off. cargo test -p litellm-providers → all pool tests pass (concurrent refill + backoff included).

Docs in this PR

  • src/routes/realtime/README.md — pooling design next to the code: handoff diagram, sizing rule, config knobs, caveats.
  • benchmarks/realtime/README.md — the results table above + how to reproduce.

The load-gen harness lives in its own repo so it doesn't bloat litellm: https://github.com/ishaan-berri/litellm-realtime-bench (no keys hardcoded; bearer token via -key/$OPENAI_API_KEY).

@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ishaan-berri
ishaan-berri force-pushed the litellm_rust_gateway_realtime_pool branch from 70baf2e to 5ee042c Compare June 24, 2026 03:12
…v stand-in)

Minimal multi-stage image for load-testing the realtime pool: builds the
gateway with default features (no python-config, no libpython), runs on a
debian-slim base (~157MB), and reads model_list from the OPENAI_REALTIME_MODEL
env stand-in. No config.yaml or pip install needed. Build context is the repo
root; only litellm-rust/ is included via the sidecar .dockerignore.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ ishaan-jaff
❌ ishaan-berri
You have signed the CLA already but the status is still pending? Let us recheck it.

Teaches an agent how to benchmark any ai-gateway endpoint: deploy the gateway,
run one load generator against both provider-direct and the gateway with the
same protocol, phase-decompose latency (dial/session/first-token/total),
compare at scale, and report success% + p50/p95. Documents the
benchmarks/<endpoint>/ layout and the hard no-committed-keys rule.
The measured results table lives in the PR description (numbers go stale in a
committed README). The benchmarks/realtime/ dir now holds only the sanitized
load-gen harness; the generic method is in benchmarks/SKILL.md.
Copies the ws-bench Go load generator (main.go, go.mod, go.sum, Dockerfile,
run.sh) into benchmarks/realtime/. Measures dial/session/first-audio/total per
WebSocket connection against both OpenAI-direct and the gateway. No keys are
hardcoded — the bearer token comes from -key / $OPENAI_API_KEY; default host
is api.openai.com.
Render one-off jobs don't surface stdout via the Logs API, so on a hosted
runner the long-lived web service runs the leg and PUBLISHES the result: serve.sh
decodes the base64 flag list, runs wsbench teeing output to /tmp/web/result.txt,
then serves it over HTTP so the result is fetchable at /result.txt. No secrets
are written to the served file (the -key is only in wsbench's argv). The
Dockerfile now copies both run.sh and serve.sh.
Remove any prior result.txt at startup and write the new run to a .partial file
that's atomically moved into place only once complete. Prevents a fetcher from
reading a previous run's numbers while the current run is still in flight.
…t scale

The replenisher dialed missing warm sockets sequentially, so a full refill cost
needed x handshake (~needed x 350ms). Under high connect rates the pool drained
faster than it refilled and ~85% of connects missed (measured: only ~15% pool
hits at 5000/500). Firing the dials together with join_all refills in ~one
handshake window, keeping warm supply close to peak concurrent connects so the
sub-millisecond warm handoff becomes the median rather than the lucky-hit tail.
Each warm_one dial is independent (no shared state until the final push under the
lock), so concurrent refill is safe. Pool unit tests unchanged and passing.
The lean load-test image isn't worth carrying in the repo; deploy the gateway
however you normally do and set the pool env vars.
…repo)

Drop the Go load-gen, Dockerfile, run.sh, serve.sh, and the generic SKILL.md from
the repo. The harness now lives at github.com/ishaan-berri/litellm-realtime-bench;
benchmarks/realtime/README.md carries the results table and links there for repro.
Pooling is now documented as a section in src/routes/realtime/README.md next to
the code (handoff diagram, sizing rule, config, notes) instead of the standalone
REALTIME_POOL_DESIGN.md RFC. Update the realtime_pool.rs doc pointer to it.
@ishaan-berri
ishaan-berri marked this pull request as ready for review June 24, 2026 04:17
Use .into_iter().flatten() instead of an if-let-Ok in the for loop over the
join_all results, and let rustfmt wrap it. Clears the CI clippy -D warnings
failure; fmt + clippy + cargo test all green locally.
@ishaan-berri
ishaan-berri enabled auto-merge (squash) June 24, 2026 04:23
@greptile-apps

greptile-apps Bot commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a pre-warmed upstream WebSocket connection pool to the Rust ai-gateway's realtime route, eliminating the per-connect session.created handshake latency (~360 ms → ~0 ms at the p50). A background replenisher keeps a configurable number of already-connected OpenAI sockets ready; on a client connect the pool hands off a warm socket (sub-millisecond local Vec::pop) and falls back to fresh-dial on miss — the pool is never on the correctness path.

  • Pool mechanics (realtime_pool.rs): concurrent refill via join_all, exponential backoff for broken keys, MissedTickBehavior::Skip, per-key backoff tracking, and non-blocking liveness check at handoff. Configurable via REALTIME_POOL_SIZE (default 4; 0 disables) and REALTIME_POOL_MAX_IDLE_SECS (default 30).
  • Refactored realtime.rs: dial_upstream, splice, and read_event extracted so the pool reuses the same types and transforms as the cold path; realtime_warm relays the buffered session.created before splicing.
  • Integration: pool registered at startup per deployment, threaded through AppState → mod.rs → service.rs; well-covered by unit tests against an in-process fake OpenAI WS server.

Confidence Score: 4/5

The fallback to fresh-dial on any pool miss means the pool cannot break realtime sessions for correctness, but one gap in warm_one — accepting any first JSON frame without checking the event type — can cause clients to receive a non-session.created event (e.g. an OpenAI application-level error) in place of the real one.

The overall design is sound and the cold-path fallback ensures the pool is never a correctness dependency. However, warm_one calls read_event and stores the result unconditionally in session_created without verifying event_type == "session.created". If OpenAI sends an application-level error event after accepting the WebSocket, warm_one succeeds, any_ok is set, backoff is cleared, and every subsequent client receives that error event where session.created is expected.

litellm-rust/crates/providers/src/realtime_pool.rs — specifically the warm_one function's handling of the first upstream event.

Important Files Changed

Filename Overview
litellm-rust/crates/providers/src/realtime_pool.rs New file implementing the pre-warmed connection pool. Exponential backoff, liveness checks, and concurrent refill are all present, but warm_one accepts any JSON first-frame as the session.created event without validating the event type — meaning application-level error events from OpenAI would be silently cached and handed to clients.
litellm-rust/crates/providers/src/realtime.rs Refactored to expose dial_upstream, splice, and read_event so the pool can reuse them; the public realtime function is preserved with identical semantics, and the new realtime_warm correctly relays the buffered prelude before splicing.
litellm-rust/crates/ai-gateway/src/main.rs Pool built and registered at startup; MissedTickBehavior::Skip is set, the disabled path is handled, and the weak-ref shutdown guard is correct.
litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs Warm path added cleanly before the cold-dial fallback; pool is never on the correctness path — a miss or dead socket falls through to fresh-dial exactly as before.
litellm-rust/crates/ai-gateway/src/routes/realtime/mod.rs Propagates the pool through the bridge closure; axum-layer changes are minimal and correct.
litellm-rust/crates/ai-gateway/src/state.rs Adds realtime_pool: Arc<RealtimePool> to shared state; trivial and correct.
litellm-rust/crates/core/src/router/mod.rs Adds a read-only deployments() accessor so main.rs can enumerate upstreams without coupling to internals; no behavior change.
litellm-rust/crates/providers/src/lib.rs Adds pub mod realtime_pool; one-line change, correct.

Comments Outside Diff (1)

  1. litellm-rust/crates/providers/src/realtime_pool.rs, line 1051-1062 (link)

    P1 First event not validated as session.created

    warm_one reads the first text frame from OpenAI and stores it unconditionally in session_created, but read_event accepts any parseable JSON — it doesn't check event_type. If OpenAI sends an application-level error as the first frame (e.g. a rate-limit or model-not-found event accepted at the WS level) warm_one still returns Ok, any_ok becomes true, backoff is cleared, and every subsequent client on this key receives that error event relayed in place of session.created. A one-line guard after read_event would fix this: reject any event whose event_type != "session.created" by returning Err(CoreError::InvalidResponse(...)), so the socket is discarded and the failure is correctly counted toward backoff.

Reviews (3): Last reviewed commit: "docs: document the replenisher backoff i..." | Re-trigger Greptile

Comment thread litellm-rust/crates/providers/src/realtime_pool.rs
Comment thread litellm-rust/crates/providers/src/realtime_pool.rs
Comment thread litellm-rust/crates/providers/src/realtime_pool.rs Outdated
Comment thread litellm-rust/crates/ai-gateway/src/routes/realtime/README.md
ishaan-berri and others added 3 commits June 23, 2026 21:33
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
A key whose warm-up dials all fail (invalid credentials, an unreachable upstream)
was re-dialed on every 250 ms tick — firing 'needed' concurrent TLS connection
attempts against the upstream indefinitely, risking rate-limit exhaustion that
would degrade valid cold-path traffic.

Add per-key exponential backoff (500 ms base, doubling, 30 s cap): after a pass
whose dials all fail, the key is skipped until its retry window elapses; any
successful dial resets it. The backoff map has its own lock so the request hot
path (take) never contends on it. Two tests added: a broken key enters backoff
and is not re-dialed during the window; a healthy key never backs off.
@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — addressed the replenisher backoff finding.

The background replenisher now puts a key into exponential backoff (500 ms base, doubling, 30 s cap) after a pass whose warm-up dials all fail (invalid credentials / unreachable upstream), instead of firing needed concurrent TLS dials against the upstream on every 250 ms tick. Backoff resets on the first successful dial; the backoff map has its own lock so the request hot path (take) never contends on it. Added two unit tests: a broken key enters backoff and is not re-dialed during the window, and a healthy key never backs off. fmt + clippy (-D warnings) + cargo test all green.

@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptileai review

@tin-berri tin-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Fix is_dead to not treat Ping/Pong as death (+ a regression test) — otherwise the optimization silently degrades at steady state.
  2. Drop the clippy::too_many_arguments allow by bundling the upstream tx/rx into a struct — the repo doesn't suppress lints, it fixes the type.
  3. Batch the refill push under one lock (folds together with replacing the any_ok mutable flag), and cap REALTIME_POOL_SIZE / add the idle-cost + ping caveats to the README.

@ishaan-berri
ishaan-berri merged commit 6072019 into litellm_internal_staging Jun 24, 2026
123 of 125 checks passed
@ishaan-berri
ishaan-berri deleted the litellm_rust_gateway_realtime_pool branch June 24, 2026 04:49
@ishaan-berri ishaan-berri mentioned this pull request Jun 25, 2026
9 of 13 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants