Skip to content

fix(realtime): bound Vertex credential resolution and make realtime failures loud - #37604

Merged
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_lit5867_realtime_silent_hang
Aug 20, 2026
Merged

fix(realtime): bound Vertex credential resolution and make realtime failures loud#37604
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_lit5867_realtime_silent_hang

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • /v1/realtime accepts the upgrade, then goes silent for minutes
  • A stalled Google OAuth token fetch blocks before any session event
  • Failures close with a bare 1011 and no error event
  • Callers see an open socket, no frames, and no reason

How it solves it:

  • Bound the pre-session Vertex token fetch at 20s, env-tunable
  • Send an OpenAI-style error event before closing the socket
  • Put the real failure text in the close reason
  • Truncate close reasons by bytes so the close frame itself survives

User Flow

Before: a developer pointing a voice app at a Vertex AI Live model gets a socket that connects and then never says anything

  1. They open a WebSocket to wss://litellm-domain/v1/realtime?model=gemini-live-2.5-flash-native-audio with their virtual key
  2. The upgrade is accepted in under a second, so the client reports itself connected
  3. No session.created arrives, no error event arrives, and the socket sits open with zero frames on it
  4. Nearly four minutes later the socket closes with code 1011 and the reason Internal server error, having delivered nothing
  5. The same app pointed at gpt-realtime-2025-08-28 on the same gateway connects and streams normally, so nothing on the client side looks broken

After: the same connection fails within about a minute and says exactly what went wrong

  1. They open a WebSocket to wss://litellm-domain/v1/realtime?model=gemini-live-2.5-flash-native-audio with their virtual key
  2. The upgrade is accepted in under a second, so the client reports itself connected
  3. The socket delivers an error event of type server_error whose message says the Google OAuth token fetch timed out after 20s and names egress to oauth2.googleapis.com as the thing to check
  4. The socket then closes with code 1011 carrying that same message as the close reason, so a client that only logs close frames still learns the cause
  5. A Vertex AI Live model with reachable credentials still returns session.created in under a second, and gpt-realtime-2025-08-28 is unchanged

Relevant issues

Linear ticket

Resolves LIT-5867

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Shared setup, identical on both sides. One proxy against real Vertex AI and real OpenAI, on a random high port, with three Vertex AI Live deployments registered through /model/new: one shaped the way a customer's config is (tags: [], guardrails: [], vector_store_ids: []), one clean, and one whose service-account JSON has its token_uri pointed at an unroutable address so the OAuth token fetch hangs the way blocked egress does in the customer's cluster.

# proxy, same command for both sides, only the checked-out commit differs
DATABASE_URL=postgresql://.../litellm_lit5867 \
GOOGLE_APPLICATION_CREDENTIALS=/path/to/vertex-sa.json \
VERTEXAI_PROJECT=vertex-check-481318 VERTEXAI_LOCATION=us-central1 \
  .venv/bin/python litellm/proxy/proxy_cli.py --config lit5867_config.yaml --detailed_debug --port <port>

# a realtime client cannot be driven with curl, so this is the whole probe
cat > ws_probe.py <<'PY'
import asyncio, sys, time
import websockets

PORT, MODEL = sys.argv[1], sys.argv[2]
LISTEN_S = float(sys.argv[3])

async def main():
    url = f"ws://127.0.0.1:{PORT}/v1/realtime?model={MODEL}"
    print(f"$ probe {url}")
    t0 = time.monotonic()
    ws = await websockets.connect(url, additional_headers={"Authorization": "Bearer sk-1234"}, open_timeout=15)
    print(f"  +{time.monotonic()-t0:.1f}s upgrade ACCEPTED")
    n, deadline = 0, time.monotonic() + LISTEN_S
    while time.monotonic() < deadline:
        try:
            raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.monotonic())
        except asyncio.TimeoutError:
            break
        except websockets.ConnectionClosed as e:
            print(f"  +{time.monotonic()-t0:.1f}s CLOSED code={e.rcvd.code} reason={e.rcvd.reason!r}")
            print(f"  RESULT: frames_before_close={n}")
            return
        n += 1
        print(f"  +{time.monotonic()-t0:.1f}s frame {n}: {raw[:300]}")
        if n >= 3:
            break
    print(f"  RESULT: {n} frame(s), socket still open after {LISTEN_S:.0f}s, state={ws.state.name}")
    await ws.close()

asyncio.run(main())
PY

Before (5290150)

Vertex AI Live, OAuth token endpoint unreachable

  1. python ws_probe.py 41207 gemini-live-badtoken 300
  2. Output, the reported symptom: the upgrade is accepted, then 230 seconds pass with nothing on the socket, and the close carries no usable reason
$ probe ws://127.0.0.1:41207/v1/realtime?model=gemini-live-badtoken
  +0.1s upgrade ACCEPTED
  +229.9s CLOSED code=1011 reason='Internal server error'
  RESULT: frames_before_close=0

Vertex AI Live, working credentials

  1. python ws_probe.py 41207 gemini-live-2.5-flash-native-audio 20 and python ws_probe.py 41207 gemini-live-clean 20
  2. Output, both shapes healthy:
$ probe ws://127.0.0.1:41207/v1/realtime?model=gemini-live-2.5-flash-native-audio
  +0.2s upgrade ACCEPTED
  +0.9s frame 1: {"type": "session.created", "session": {"id": "8e7a0431-a2b4-46f1-9f3f-1d2f06c31e6e", "modalities": ["audio"], "model": "gemini-live-2.5-flash-native-audio"}, "event_id": "8566f590-35e7-4f2c-a547-6870ddd0354a"}
  RESULT: 1 frame(s), socket still open after 20s, state=OPEN

$ probe ws://127.0.0.1:41207/v1/realtime?model=gemini-live-clean
  +0.1s upgrade ACCEPTED
  +0.5s frame 1: {"type": "session.created", "session": {"id": "e3b37e4b-e3ef-4750-90a8-2b83a2e3dda7", "modalities": ["audio"], "model": "gemini-live-2.5-flash-native-audio"}, "event_id": "22fd105a-ec38-4fc9-a9e4-147350848092"}
  RESULT: 1 frame(s), socket still open after 20s, state=OPEN

OpenAI realtime control

  1. python ws_probe.py 41207 gpt-realtime-2025-08-28 20
  2. Output, full duplex on the same gateway:
$ probe ws://127.0.0.1:41207/v1/realtime?model=gpt-realtime-2025-08-28
  +0.0s upgrade ACCEPTED
  +0.3s frame 1: {"type": "session.created", "event_id": "event_EEt1C4H04kvAwV0ugO3Fe", "session": {"type": "realtime", "object": "realtime.session", "id": "sess_EEt1CgHd6MEibZlpzVBzX", "model": "gpt-realtime-2025-08-28", "output_modalities": ["audio"], "instructions": "Your knowledge cutoff is 2023-10. You are a he
  RESULT: 1 frame(s), socket still open after 20s, state=OPEN

After (d643136)

Vertex AI Live, OAuth token endpoint unreachable

  1. python ws_probe.py 39177 gemini-live-badtoken 300
  2. Output, the fix: an error event naming the timeout arrives at 65s instead of 230s of silence, and the close reason carries the same text
$ probe ws://127.0.0.1:39177/v1/realtime?model=gemini-live-badtoken
  +0.0s upgrade ACCEPTED
  +64.8s frame 1: {"type": "error", "error": {"type": "server_error", "message": "Vertex AI realtime: timed out fetching Google OAuth access token after 20.0s; check network egress from the proxy to the OAuth token endpoint (oauth2.googleapis.com)"}}
  +64.8s CLOSED code=1011 reason='Vertex AI realtime: timed out fetching Google OAuth access token after 20.0s; check network egress from the proxy to the OA'
  RESULT: frames_before_close=1

Vertex AI Live, working credentials

  1. python ws_probe.py 39177 gemini-live-2.5-flash-native-audio 20 and python ws_probe.py 39177 gemini-live-clean 20
  2. Output, unchanged from Before:
$ probe ws://127.0.0.1:39177/v1/realtime?model=gemini-live-2.5-flash-native-audio
  +0.0s upgrade ACCEPTED
  +0.7s frame 1: {"type": "session.created", "session": {"id": "949d9210-0e03-424a-aa8b-9f2d633b2fb8", "modalities": ["audio"], "model": "gemini-live-2.5-flash-native-audio"}, "event_id": "e7e041fd-0ae9-4bae-bf49-a62624a52673"}
  RESULT: 1 frame(s), socket still open after 20s, state=OPEN

$ probe ws://127.0.0.1:39177/v1/realtime?model=gemini-live-clean
  +0.0s upgrade ACCEPTED
  +0.3s frame 1: {"type": "session.created", "session": {"id": "d87e7c58-e17e-45e5-8454-9a6db888d251", "modalities": ["audio"], "model": "gemini-live-2.5-flash-native-audio"}, "event_id": "4cea47bb-c9d7-4e8c-a025-a35783fe7dc8"}
  RESULT: 1 frame(s), socket still open after 20s, state=OPEN

OpenAI realtime control

  1. python ws_probe.py 39177 gpt-realtime-2025-08-28 20
  2. Output, unchanged from Before:
$ probe ws://127.0.0.1:39177/v1/realtime?model=gpt-realtime-2025-08-28
  +0.0s upgrade ACCEPTED
  +0.3s frame 1: {"type": "session.created", "event_id": "event_EEthHFekSZ6CrQBMpX3MI", "session": {"type": "realtime", "object": "realtime.session", "id": "sess_EEthHYZiDHH30LQB0hjin", "model": "gpt-realtime-2025-08-28", "output_modalities": ["audio"], "instructions": "Your knowledge cutoff is 2023-10. You are a he
  RESULT: 1 frame(s), socket still open after 20s, state=OPEN

Type

🐛 Bug Fix

Caveats (if any)

  • Router retries multiply the bound: 3 attempts, so ~65s worst case
  • 20s default stays conservative to avoid timing out healthy deployments
  • REALTIME_CREDENTIAL_RESOLUTION_TIMEOUT_SECONDS tunes it per deployment
  • Backend connect was already bounded; the token fetch was the gap
  • Failure text reaches the caller, matching what the HTTP routes already return; Veria flagged that as a disclosure risk
  • A timed-out refresh leaves its worker thread running until Google's own timeout; sequential retries stack up to three, while concurrent callers share one through the per-credential lock

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Cursor Bugbot is generating a summary for commit d643136. Configure here.

…ailures loud

A /v1/realtime connection to a Vertex AI Live model accepted the WebSocket
upgrade and then went silent: a stalled Google OAuth token fetch blocked the
handler before any session event, and the eventual failure closed the socket
with a bare 1011 and no error event, so callers saw an open socket, no frames,
and no reason.

Bound the pre-session token fetch with
REALTIME_CREDENTIAL_RESOLUTION_TIMEOUT_SECONDS (20s default) and, on any
realtime failure, send an OpenAI-style error event before closing with a reason
that names the failure. Close reasons are truncated by bytes, not characters,
since an over-long reason makes the close frame itself fail.
@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 sign our Contributor License Agreement before we can accept your contribution.


Mateo seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Bounds Vertex AI realtime credential resolution and improves WebSocket failure reporting

  • Adds a configurable timeout around Vertex access-token resolution for realtime requests and health checks
  • Sends OpenAI-style error events before closing failed realtime WebSockets
  • Safely truncates WebSocket close reasons by UTF-8 byte length
  • Adds focused regression coverage for timeout, error-event, close-frame, and disconnected-client behavior

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/realtime_api/main.py Adds bounded Vertex credential resolution to realtime sessions and health checks without leaving an eligible blocking issue
litellm/litellm_core_utils/realtime_errors.py Introduces shared error-event serialization and RFC-compliant close-reason truncation
litellm/llms/custom_httpx/llm_http_handler.py Sends a structured error event and reasoned close when realtime backend setup fails
litellm/proxy/proxy_server.py Makes phase-two realtime routing failures visible while tolerating an already-closed client socket
litellm/types/llms/vertex_ai.py Adds the typed asynchronous access-token resolver protocol used by bounded credential resolution
litellm/types/realtime.py Defines typed OpenAI-style realtime error event payloads
tests/test_litellm/realtime_api/test_main.py Covers successful, stalled, thread-offloaded, and wired Vertex credential resolution
tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py Verifies phase-two failures produce an error event and safe close behavior

Reviews (3): Last reviewed commit: "fix(realtime): resolve the vertex token ..." | Re-trigger Greptile

Comment thread litellm/realtime_api/main.py
Comment thread litellm/realtime_api/main.py Outdated
await websocket.close(code=1011, reason="Internal server error")
redacted_error: Final = _redact_string(str(e))
try:
await websocket.send_text(realtime_error_event(redacted_error, error_type="server_error"))

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.

Low: Internal exception disclosure

When realtime routing or backend setup fails, an authenticated caller now receives the raw exception text. _redact_string removes recognized credential patterns, but leaves details such as GCP project IDs, private upstream hostnames, and filesystem paths; the same issue occurs in litellm/llms/custom_httpx/llm_http_handler.py:5982. Return a fixed public error message for unexpected exceptions, whitelist specific safe messages such as the credential timeout, and retain the full exception only in server logs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

llm_http_handler already sent this redacted text before the PR. Clamping the proxy to a fixed string restores the opaque failure this fix removes

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.

Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.

@veria-ai

veria-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request bounds Vertex credential resolution and changes realtime routing and backend setup failures to surface errors more clearly. It updates realtime proxy and custom HTTP handling paths.

One low-impact information disclosure remains open: an authenticated caller who triggers certain realtime failures may receive raw exception details, including project IDs, internal hostnames, or filesystem paths. No issues have yet been addressed, so unexpected exceptions should still be replaced with a fixed public message while full details remain in server logs.

Open issues (1)

Fixed/addressed: 0 · PR risk: 3/10

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Take the resolver and its timeout as parameters of the bounded helper and
bind the vertex one once at module level, so the timeout tests drive an
injected fake instead of patching a shared singleton.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Binding the bound method at import froze the module-level VertexBase
instance, so callers that swap it no longer reached their replacement.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Timeout cancels Vertex refresh lock
    • Scheduled the resolver as a Task, added a module-level strong-ref registry, and shielded it inside wait_for so the inflight refresh keeps VertexBase's per-key async lock held past timeout, forcing retries to queue on it rather than spawn new asyncify(refresh_auth) worker threads that would starve anyio's thread limiter.

Create PR

Or push these changes by commenting:

@cursor push 7d4980c7e6
Preview (7d4980c7e6)
diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py
--- a/litellm/realtime_api/main.py
+++ b/litellm/realtime_api/main.py
@@ -299,21 +299,33 @@
     )
 
 
+_INFLIGHT_VERTEX_TOKEN_TASKS: Final[set[asyncio.Task[tuple[str, str]]]] = set()  # mutable-ok: strong-ref registry so bounded resolvers keep running past timeout and keep VertexBase's per-key async refresh lock held; without it retries would spawn new asyncify(refresh_auth) threads that queue on the instance-wide _sync_refresh_lock and starve anyio's thread limiter
+
+
+def _drop_inflight_vertex_token_task(task: asyncio.Task[tuple[str, str]]) -> None:
+    _INFLIGHT_VERTEX_TOKEN_TASKS.discard(task)
+    if not task.cancelled():
+        # Consume the exception so tasks that outlived their bounded caller don't emit "exception was never retrieved" warnings.
+        _ = task.exception()
+
+
 async def _resolve_vertex_access_token_bounded(
     credentials: VERTEX_CREDENTIALS_TYPES | None,
     project_id: str | None,
     resolver: VertexAccessTokenResolver,
     timeout_seconds: float,
 ) -> tuple[str, str]:
+    task: Final = asyncio.ensure_future(
+        resolver(
+            credentials=credentials,
+            project_id=project_id,
+            custom_llm_provider="vertex_ai",
+        )
+    )
+    _INFLIGHT_VERTEX_TOKEN_TASKS.add(task)
+    task.add_done_callback(_drop_inflight_vertex_token_task)
     try:
-        return await asyncio.wait_for(
-            resolver(
-                credentials=credentials,
-                project_id=project_id,
-                custom_llm_provider="vertex_ai",
-            ),
-            timeout=timeout_seconds,
-        )
+        return await asyncio.wait_for(asyncio.shield(task), timeout=timeout_seconds)
     except asyncio.TimeoutError as e:
         raise ValueError(
             "Vertex AI realtime: timed out fetching Google OAuth access token after "

diff --git a/tests/test_litellm/realtime_api/test_main.py b/tests/test_litellm/realtime_api/test_main.py
--- a/tests/test_litellm/realtime_api/test_main.py
+++ b/tests/test_litellm/realtime_api/test_main.py
@@ -153,6 +153,52 @@
 
 
 @pytest.mark.asyncio
+async def test_bounded_resolver_keeps_inflight_refresh_alive_on_timeout():
+    """Regression: `asyncio.wait_for` used to cancel the token resolver on
+    timeout, which released VertexBase's per-key async refresh lock while the
+    underlying google-auth refresh thread kept running holding the instance-wide
+    `_sync_refresh_lock`. Router retries would then spawn additional refresh
+    threads that queued on that sync lock and starved anyio's thread limiter.
+    The bounded resolver must let the inflight resolution keep running past the
+    caller's bound so the per-key async lock stays held and later callers
+    (retries or unrelated realtime traffic) queue on the async lock instead of
+    spawning new blocking refresh threads."""
+    started = asyncio.Event()
+
+    async def slow_resolver(credentials, project_id, custom_llm_provider) -> tuple[str, str]:
+        started.set()
+        await asyncio.sleep(30)
+        return "", ""
+
+    before = set(realtime_main._INFLIGHT_VERTEX_TOKEN_TASKS)
+    with pytest.raises(ValueError, match="timed out fetching Google OAuth access token"):
+        await realtime_main._resolve_vertex_access_token_bounded(
+            credentials="fake-credentials",
+            project_id="fake-project",
+            resolver=slow_resolver,
+            timeout_seconds=0.05,
+        )
+    assert started.is_set()
+    new_tasks = realtime_main._INFLIGHT_VERTEX_TOKEN_TASKS - before
+    assert len(new_tasks) == 1
+    inflight = next(iter(new_tasks))
+    try:
+        assert not inflight.done(), (
+            "Bounded resolver ended the inflight token refresh on timeout: "
+            "VertexBase's per-key async lock would be released while the refresh "
+            "thread still holds _sync_refresh_lock, letting retries spawn new "
+            "refresh threads that queue on the sync lock"
+        )
+        assert not inflight.cancelled()
+    finally:
+        inflight.cancel()
+        try:
+            await inflight
+        except BaseException:
+            pass
+
+
+@pytest.mark.asyncio
 async def test_arealtime_vertex_branch_resolves_credentials_under_a_bound(monkeypatch):
     """The wiring half of the regression: the vertex branch of _arealtime must
     go through the bounded resolver, so a hung token refresh surfaces as a

You can send follow-ups to the cloud agent here.

Comment thread litellm/realtime_api/main.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5867_realtime_silent_hang (d643136) with litellm_internal_staging (6fcdea0)

Open in CodSpeed

@cursor cursor Bot 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit d643136. Configure here.

@mateo-berri
mateo-berri merged commit 5bee64c into litellm_internal_staging Aug 20, 2026
76 checks passed
@mateo-berri
mateo-berri deleted the litellm_lit5867_realtime_silent_hang branch August 20, 2026 17:24
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.

3 participants