fix(proxy): gate the OpenAI websocket passthrough behind an explicit opt-in - #39841
Conversation
Greptile SummaryThis PR adds explicit administrative opt-in and effective model-scope admission checks for OpenAI WebSocket passthrough routes.
Confidence Score: 5/5The PR appears safe to merge; no new actionable issue or outstanding previous finding remains. The database configuration overlay now activates the opt-in when YAML does not override it, and lookup failures remain fail-closed while genuinely absent user rows are handled explicitly. The other previous findings were resolved after correct disputes or were manually resolved.
|
| Filename | Overview |
|---|---|
| litellm/proxy/auth/auth_checks.py | Resolves all enforced model allowlists and distinguishes missing users from unreadable user records. |
| litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py | Gates OpenAI WebSocket passthrough and sends typed error frames for disabled or restricted connections. |
| litellm/proxy/proxy_server.py | Applies the database-backed passthrough setting while retaining YAML precedence. |
| litellm/proxy/_types.py | Adds the optional general setting to the proxy configuration model. |
| tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py | Covers disabled, enabled, and model-restricted WebSocket admission behavior. |
| tests/test_litellm/proxy/auth/test_auth_checks.py | Covers effective allowlist resolution and missing-versus-failed user lookup behavior. |
Reviews (7): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile
| enable_openai_websocket_passthrough: bool | None = Field( | ||
| default=None, | ||
| description="Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.", | ||
| ) |
There was a problem hiding this comment.
The new field allows /config/update to persist this setting and report a successful configuration update, but _update_general_settings never copies it from the database into the live general_settings mapping. An administrator who enables passthrough through the supported management API will therefore continue receiving disabled errors on every connection. The setting needs to participate in database-overlay reconciliation with the intended YAML precedence.
Knowledge Base Used: Proxy configuration and deployment
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
…el allowlist and propagate the DB opt-in
|
bugbot run |
There was a problem hiding this comment.
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: Team restrictions skipped without database
- The no-prisma branch of
enforced_model_allowlistsnow also returns the token'steam_modelslist, so JWT/no-DB identities with a team model allowlist stay restricted on the OpenAI websocket passthrough.
- The no-prisma branch of
Or push these changes by commenting:
@cursor push 8f67fe512f
Preview (8f67fe512f)
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -4164,7 +4164,7 @@
"""One model allowlist per level that ``common_checks`` enforces on a request from this identity."""
key_models: Final = _resolve_key_models_for_auth_check(valid_token=valid_token)
if prisma_client is None:
- return (key_models,)
+ return (key_models, tuple(valid_token.team_models or ()))
team_object: Final = (
None
if valid_token.team_id is None
diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py
--- a/tests/test_litellm/proxy/auth/test_auth_checks.py
+++ b/tests/test_litellm/proxy/auth/test_auth_checks.py
@@ -7397,6 +7397,18 @@
user_api_key_cache=cache,
proxy_logging_obj=proxy_logging_obj,
)
+ without_database_team_only = await enforced_model_allowlists(
+ valid_token=UserAPIKeyAuth(
+ token="hashed-fake",
+ models=[],
+ team_models=["gpt-4o"],
+ user_id="user-fake",
+ team_id="team-fake",
+ ),
+ prisma_client=None,
+ user_api_key_cache=cache,
+ proxy_logging_obj=proxy_logging_obj,
+ )
assert [list(scope) for scope in team_scoped] == [
["gpt-4o", "gpt-4o-mini"],
@@ -7406,4 +7418,5 @@
["gpt-4.1"],
]
assert [list(scope) for scope in personal] == [[], [], [], ["o3"], []]
- assert [list(scope) for scope in without_database] == [["gpt-4o"]]
+ assert [list(scope) for scope in without_database] == [["gpt-4o"], []]
+ assert [list(scope) for scope in without_database_team_only] == [[], ["gpt-4o"]]You can send follow-ups to the cloud agent here.
…ough gate without a database
… passthrough gate
…n the websocket passthrough gate
|
bugbot run |
…dDict The two dict literals behind the refusal event counted against the LIT002 ceiling once the base branch used up its headroom, so the frame is now a ReadOnly TypedDict built in one shot. Importing Literal explicitly also makes the UP037 suppression on the Vertex discovery signature unnecessary, so it goes.
…itellm_gate_openai_ws_passthrough
|
bugbot run |
There was a problem hiding this comment.
✅ 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 5c80e30. Configure here.
c52b537
into
litellm_internal_staging

TLDR
Problem this solves:
modelquery string, and/openai/v1/responseshas none/openai_passthrough/*and/openai/*WebSocket routes were on by defaultHow it solves it:
general_settings.enable_openai_websocket_passthrough: trueturns them back on, from YAML or throughPOST /config/field/updatewhenstore_model_in_dbis on (the field is not on the Admin UI general settings page, which only lists the alerting fields); every pod picks the DB value up on its next config reload and a YAML value always winserrorevent naming the setting, then close 1008errorevent naming the reason instead of a relay, on both passthrough routes, whether or not the URL names a modelUser Flow
Before: a developer with an ordinary virtual key opens a WebSocket through the passthrough prefix and the gateway relays it to OpenAI on its own credential, for a model the gateway does not list, with no spend recorded
model_listholdinggpt-5.6andgpt-realtime-2.1-mini, both on the proxy's own OpenAI key, and no passthrough settings at allwss://litellm-domain/openai_passthrough/v1/realtime?model=gpt-realtime-2.1withAuthorization: Bearer <their key>session.createdevent comes back naminggpt-realtime-2.1, a model the proxy never listedsession.update,conversation.item.create, andresponse.create, andresponse.donearrives with the model's answerwss://litellm-domain/openai/v1/responsesthe same way, send oneresponse.createforgpt-5.6, andresponse.completedarrives with the answergpt-5.6, is refused at the handshake on the realtime URL (its query string names a model) and relayed onwss://litellm-domain/openai/v1/responsesexactly like step 5, because that handshake names no modelunknown, spend $0.00, and zero tokens, andGET /key/infofor those keys still reports spend 0POST /config/field/updateand gets400 Invalid field=enable_openai_websocket_passthrough passed in.; there is no switchAfter: the same connections are refused with a message naming the setting until the admin opts in, the proxy's own realtime route keeps working, and every model restriction refuses the relay once it is on
model_listholdinggpt-5.6andgpt-realtime-2.1-mini, both on the proxy's own OpenAI key, and no passthrough settings at allwss://litellm-domain/openai_passthrough/v1/realtime?model=gpt-realtime-2.1withAuthorization: Bearer <their key>{"type": "error", "error": {"type": "invalid_request_error", "message": "OpenAI websocket passthrough is disabled on this gateway. A proxy admin can turn it on by setting general_settings.enable_openai_websocket_passthrough to true."}}, and the socket closes with code 1008wss://litellm-domain/openai/v1/responsesanswers with the same error event and close codewss://litellm-domain/v1/realtime?model=gpt-realtime-2.1-mini, and the session runs end to end for that listed modelgeneral_settings: {enable_openai_websocket_passthrough: true}and restarts, or, withstore_model_in_db: true, sendsPOST /config/field/updatewith{"field_name": "enable_openai_websocket_passthrough", "field_value": true, "config_type": "general_settings"}(the Admin UI general settings page does not list this field) and every pod picks it up within the config reload interval with no restart; the passthrough connections in steps 2 and 4 now run as before, andPOST /config/field/deleteturns them back off the same way{"type": "error", "error": {"type": "invalid_request_error", "message": "Keys with model restrictions cannot use OpenAI websocket passthrough, because this route relays frames to the provider without reading which model they ask for."}}and close 1008 on both passthrough routes, even when the restriction allows the model in the URLRelevant issues
The WebSocket passthrough route was added for #36088 and shipped on by default in v1.99.0; this PR keeps that capability behind an explicit opt-in
Linear ticket
Resolves LIT-6849
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
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@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Shared setup for every run below. Real OpenAI key in the environment, real spend. Every proxy runs with
--num_workers 2against a Postgres database; the DB opt-in run boots two separate proxy processes on two ports sharing that one database.PORTis whichever free port that run usedws_client.pyis the client used for every WebSocket leg: it connects withAuthorization: Bearer <key>, sends each--sendframe, prints every event type it gets back (full text forerror,session.*, andresponse.completed), and prints the close code and reason when the server closesws_client.py
Before (59d42d3)
An unrestricted key drives a realtime model that is not in model_list through the passthrough
realtime_leg "$REALTIME" "$UNRESTRICTED"gpt-realtime-2.1is not inmodel_list, and the answer comes back anyway:The same key drives the Responses API over the
/openai/v1/responsesWebSocket passthroughresponses_leg "$RESPONSES" "$UNRESTRICTED"Keys with a model list are refused at the handshake, whichever model they name
realtime_leg "$REALTIME" "$RESTRICTED_GPT56"HANDSHAKE REJECTED: HTTP 403realtime_leg "$REALTIME" "$RESTRICTED_RT"HANDSHAKE REJECTED: HTTP 403(the key allows exactly the model it asked for, and it is still refused, because the route cannot read which model the frames name)The proxy's own
/v1/realtimeroute, for referencerealtime_leg "$MANAGED" "$UNRESTRICTED"gpt-realtime-2.1-minifrommodel_list:... RECV <- response.output_text.done text='LIT6849' ...What the proxy recorded for the passthrough sessions
curl -s "http://127.0.0.1:$PORT/key/info" -H "Authorization: Bearer $UNRESTRICTED" | jq ".info.spend, .info.models"0.0and[]curl -s "http://127.0.0.1:$PORT/spend/logs" -H "Authorization: Bearer $LITELLM_MASTER_KEY" | jq -c ".[] | {model, spend, total_tokens, api_base}"A key with no model list of its own, owned by a user restricted to gpt-5.6, is relayed on the Responses WebSocket
curl -s "http://127.0.0.1:$PORT/user/info?user_id=lit6849-user-gpt56" "${H[@]}" | jq .user_info.modelsprints["gpt-5.6"]curl -s "http://127.0.0.1:$PORT/key/info" -H "Authorization: Bearer $USER_RESTRICTED" | jq '{models: .info.models, user_id: .info.user_id, team_id: .info.team_id}'prints{"models": [], "user_id": "lit6849-user-gpt56", "team_id": null}realtime_leg "$REALTIME" "$USER_RESTRICTED"getsHANDSHAKE REJECTED: HTTP 403: the query string names a model the user may not call, so the handshake auth catches this oneresponses_leg "$RESPONSES" "$USER_RESTRICTED"The admin has no switch
curl -s -X POST "http://127.0.0.1:$PORT/config/field/update" "${H[@]}" -d '{"field_name":"enable_openai_websocket_passthrough","field_value":false,"config_type":"general_settings"}'{"detail":{"error":"Invalid field=enable_openai_websocket_passthrough passed in."}}with HTTP 400After (5c80e30)
The default-config legs, the enabled-config legs and the master key probe ran at 5c80e30, the tip that merges litellm_internal_staging on top of aca1c54. The refusal frames and the relayed sessions below came out byte for byte the same as the a0b2e7f run (ids aside). The DB opt-in legs ran at f846388 and the no-database leg at 2a7fc8d; the commits since only change what happens when a user lookup fails and how the refusal frame is built, and the default and enabled legs at 5c80e30 cover both
Default config, nothing added: both passthrough prefixes refuse with a readable reason
realtime_leg "$REALTIME" "$UNRESTRICTED"errorevent naming the setting and closes with 1008. Nothing reaches OpenAI:responses_leg "$RESPONSES" "$UNRESTRICTED"/openai/prefix:Default config: the proxy's own
/v1/realtimeroute is untouchedrealtime_leg "$MANAGED" "$UNRESTRICTED"... RECV <- session.created {... "model": "gpt-realtime-2.1-mini", ...} ... RECV <- response.output_text.done text='LIT6849' ... RECV <- response.done {"type": "response.done", ... "status": "completed", ...Opted in from YAML: the relay behaves exactly as before for unrestricted keys
realtime_leg "$REALTIME" "$UNRESTRICTED"responses_leg "$RESPONSES" "$UNRESTRICTED"... RECV <- response.output_text.done text='LIT6849' ... RECV <- response.completed {"type": "response.completed", "response": {"id": "resp_<redacted>", "object": "response", ... "status": "completed", ...Opted in: keys with a model list are still refused, now with a reason instead of a bare 403
realtime_leg "$REALTIME" "$RESTRICTED_RT"(the key allows exactly the model in the query string)realtime_leg "$REALTIME" "$RESTRICTED_GPT56"HANDSHAKE REJECTED: HTTP 403(unchanged: the key's model list excludes the model in the query string, so the handshake auth refuses it before the route runs)Opted in: the user-restricted identity from the Before run is refused on both routes
/user/infoprints["gpt-5.6"],/key/infoprints{"models": [], "user_id": "lit6849-user-gpt56", "team_id": null}realtime_leg "$REALTIME" "$USER_RESTRICTED"still getsHANDSHAKE REJECTED: HTTP 403responses_leg "$RESPONSES" "$USER_RESTRICTED"Opted in through the API: two proxy processes, one database, no restart
Both processes boot from the shared config plus
store_model_in_db: trueand noenable_openai_websocket_passthroughline,--num_workers 2each, on ports$PAand$PB, sharing one Postgres databasePORT=$PA realtime_leg "$REALTIME" "$UNRESTRICTED"gets the disablederrorevent andCLOSED code=1008 reason='OpenAI websocket passthrough is disabled'curl -s -X POST "http://127.0.0.1:$PA/config/field/update" "${H[@]}" -d '{"field_name":"enable_openai_websocket_passthrough","field_value":true,"config_type":"general_settings"}'{"param_name":"general_settings","param_value":{"enable_openai_websocket_passthrough":true},"last_run_at":null,"reload_revision":0}with HTTP 200PORT=$PB realtime_leg "$REALTIME" "$UNRESTRICTED"on the other process:curl -s -X POST "http://127.0.0.1:$PB/config/field/delete" "${H[@]}" -d '{"field_name":"enable_openai_websocket_passthrough","config_type":"general_settings"}'{"param_name":"general_settings","param_value":{},"last_run_at":null,"reload_revision":0}with HTTP 200PORT=$PA realtime_leg "$REALTIME" "$UNRESTRICTED"on the first process is refused again:Opted in, no database attached: a custom-auth identity that carries only a team model list
Custom auth hands back an identity with no key model list and a team model list of one model, and the proxy runs without
DATABASE_URLrealtime_leg "$REALTIME" "$NODB_TEAM", thenresponses_leg /openai/v1/responses "$NODB_TEAM"HTTP 403(the old check read the token's lists before accepting). At 2a7fc8d both are accepted, get the error event, and close 1008; the same output on both routes. Ran at 2a7fc8d, and the two commits after it only change the database-backed lookups, which a proxy without a database never runs:Opted in: the master key on the passthrough realtime route
realtime_leg "$REALTIME" "$LITELLM_MASTER_KEY"HTTP 500, because the admin's user row does not exist on a fresh database and the gate treated the failed lookup as fatal (found by the dependency sweep). At 5c80e30 it relays again:Opted in: what the proxy recorded
Captured during the a2d5215 run; 7351911 changes admission only, not logging
curl -s "http://127.0.0.1:$PORT/spend/logs" -H "Authorization: Bearer $LITELLM_MASTER_KEY" | jq -c ".[] | {model, spend, total_tokens, api_base}"$0rows with 0 tokens, while the proxy's own realtime route rows carry usage. That gap is what LIT-7014 tracks, and it is why the route stays off unless an admin opts in:Observations from the run:
unknown; this PR leaves that alone (LIT-7014)Type
🐛 Bug Fix
Caveats (if any)
Severe
/openai_passthrough/<ws path>or/openai/<ws path>over WebSocket since v1.99.0general_settings.enable_openai_websocket_passthrough: truein YAML, or throughPOST /config/field/updatewhenstore_model_in_dbis on (not from the Admin UI general settings page, which only lists the alerting fields); the error event says so/v1/realtime,/openai/v1/realtime,/realtime, and/v1/responsesWebSocket routes are unchanged/openai/v1/responsesdespite a team, membership, user, or project model list are now refused there tooMedium
team_idhas no team row fails the handshake with 404enable_openai_websocket_passthroughvalue always wins over the DB one, like every other DB-overlaid general settingLow
RuntimeErrorafter the close, on the merge base and on this branch alikeintegrations / Run testson the datadog span test,frontend-linton the dashboard'slocal/no-large-inline-object-argbudget, and CircleCIproxy_store_model_in_db_tests::test_chat_completion_bad_model_with_spend_logson staging's scheduled pipelines). 5c80e30 merges the staging fixes for the first two, and both are green at this tip. The merge also tripped two lint ceilings staging had ratcheted down in the meantime (LIT002 on the two dict literals of the refusal frame, and RUF100 on a# noqa: UP037that stopped being needed onceLiteralis imported directly), fixed in aca1c54 by building the frame from a TypedDict and dropping the suffix. At the moment auto-merge fired, 26 of the 29 CircleCI jobs had passed at 5c80e30 and using_litellm_on_windows, e2e_ui_testing and proxy_multi_instance_tests were still running; none of the three is a required contextFinal 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
a0b2e7f passes /live-pr-risk (every dependent path is listed under Screenshots / Proof of Fix and Caveats; the one CircleCI red at this tip is staging's own, per the Low caveat above)
5c80e30 passes /live-pr-risk (aca1c54 only changes how the refusal frame is built, from two dict literals to a TypedDict-annotated literal, and drops a
# noqa: UP037that no longer suppressed anything; the frame's JSON bytes on the wire are identical, which the default and enabled legs at 5c80e30 observe on both passthrough prefixes and for the model-restricted refusal, so every client reading that frame sees what it saw at a0b2e7f; the base merge itself had no conflicts)Note
High Risk
Changes default proxy behavior and auth for a credential-relay path that bypasses normal model and spend enforcement; misconfiguration or the opt-in flag could expose the provider key or break existing WebSocket clients.
Overview
OpenAI WebSocket passthrough is off by default and only serves
/openai/*and/openai_passthrough/*whengeneral_settings.enable_openai_websocket_passthroughis true (YAML or DB viastore_model_in_db; YAML overrides DB). Refused connections accept the handshake, send one OpenAI-styleerrorframe, then close with code 1008.When enabled, the route blocks any identity with a model restriction at any enforced level (key, team, team membership, user, project)—not just fields on the token—via new
enforced_model_allowlists(). Missing user rows are treated as unrestricted (e.g. master key on a fresh DB); provable absence usesUserNotFoundErrorinstead of a bare exception.Managed
/v1/realtimeand other non-passthrough WebSocket paths are unchanged.Reviewed by Cursor Bugbot for commit 5c80e30. Bugbot is set up for automated code reviews on this repo. Configure here.