fix(passthrough): resolve vertex live credentials from db model deployments - #37602
Conversation
…yments The /vertex_ai/live WebSocket passthrough only ever looked at default_vertex_config and the DEFAULT_VERTEXAI_* env vars, so a proxy whose Vertex credentials live in the DB as a model entry with use_in_pass_through had nothing to authenticate with. The upgrade still succeeded and the socket then closed with a bare 1000 on the first client frame, which gave the client no way to tell a misconfiguration from a normal end of session. Credentials now also resolve from the router deployments flagged use_in_pass_through, preferring the one matching the requested model, and a failure to mint an access token closes 1011 with a reason naming both ways to configure it. Upstream closes other than a plain 1000 are relayed to the client with their code and reason, so Google's own errors reach the caller. The setup frame's model is rewritten to the full projects/.../publishers/google/models resource path, which is what Vertex expects and what lets a bare model id or a gateway alias work over this route.
Greptile SummaryThe PR enables Vertex Live passthrough to resolve DB-backed deployment credentials, normalizes setup-frame model names into Vertex resource paths, and relays meaningful upstream WebSocket close details.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py | Resolves Vertex credentials from eligible deployments and now rejects ambiguity across projects, locations, and service-account identities. |
| litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py | Integrates deployment credential resolution and setup-model resource-path rewriting into the Vertex Live route. |
| litellm/proxy/pass_through_endpoints/pass_through_endpoints.py | Adds optional setup-frame rewriting and relays valid upstream WebSocket close information without double-closing the client. |
| litellm/constants.py | Defines the RFC 6455 close-reason byte limit used by the shared WebSocket relay. |
| tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py | Covers DB credential use, model normalization, default precedence, and actionable authentication failures. |
| tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py | Covers setup-frame forwarding and valid, invalid, normal, and truncated upstream close behavior. |
| tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py | Covers named and inline credentials, model hints, dict credentials, and ambiguous deployment identities. |
Reviews (4): Last reviewed commit: "fix: harden vertex live passthrough agai..." | Re-trigger Greptile
| if upstream_close is not None and websocket.application_state != WebSocketState.DISCONNECTED: | ||
| await websocket.close( | ||
| code=upstream_close.code, | ||
| reason=_truncated_close_reason(upstream_close.reason), |
There was a problem hiding this comment.
Low: Upstream resource details disclosed
An authenticated caller can submit an invalid setup model and receive the provider's raw close reason, which can contain the configured Vertex project and full upstream resource path. Keep the raw reason in server logs and return a stable client-safe message instead.
| reason=_truncated_close_reason(upstream_close.reason), | |
| reason="Upstream WebSocket closed the connection", |
There was a problem hiding this comment.
Relaying Google's reason is the fix here. The same project and path already reach callers through the HTTP vertex_ai passthrough and chat errors
There was a problem hiding this comment.
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.
PR overviewThis pull request updates passthrough handling so Vertex live credentials are resolved from database-backed model deployments. The change affects the Vertex live connection setup and upstream WebSocket handling. One low-impact disclosure remains open: an authenticated caller using an invalid setup model can receive the provider’s raw WebSocket close reason, potentially revealing the configured Vertex project and full upstream resource path. No issues have yet been addressed, but the exposure is limited to authenticated callers and upstream resource metadata. Open issues (1)
Fixed/addressed: 0 · PR risk: 4/10 |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Setup rewrite keeps model prefixes
- Extended _resolve_alias_to_upstream_model to also strip a leading 'models/' (Gemini Live SDK) and any '/' prefix (LiteLLM id) in addition to 'publishers/google/models/', so every common client naming lands as a bare id under 'publishers/google/models/{id}'.
- ✅ Fixed: Dict Vertex credentials become unusable
- Added _get_vertex_credentials_value so named-credential lookups accept dicts (validated via TypeAdapter[dict[str,str]]) and dropped the str() wrapper in the live websocket path so a dict service-account is forwarded unchanged to _ensure_access_token_async.
Or push these changes by commenting:
@cursor push 33ff3afa16
Preview (33ff3afa16)
diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
--- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
+++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
@@ -2438,30 +2438,37 @@
aliased: Final = _resolve_alias_to_upstream_model(setup_model, llm_router)
return (
f"projects/{vertex_project}/locations/{vertex_location}/"
- f"{VERTEX_PUBLISHER_MODEL_PREFIX}{aliased.removeprefix(VERTEX_PUBLISHER_MODEL_PREFIX)}"
+ f"{VERTEX_PUBLISHER_MODEL_PREFIX}{aliased}"
)
return rewrite
def _resolve_alias_to_upstream_model(setup_model: str, llm_router: "Router | None") -> str:
- if llm_router is None:
- return setup_model
- upstream: Final = next(
- (
- deployment["litellm_params"]["model"]
- for deployment in (llm_router.get_model_list() or ())
- if deployment.get("model_name") == setup_model
- ),
- None,
+ """
+ Return the bare Vertex model id for ``setup_model``, resolving router aliases and stripping any
+ ``publishers/google/models/``, ``models/`` (Gemini Live SDK), or ``<provider>/`` (LiteLLM id) prefix
+ so the caller can safely paste it into ``publishers/google/models/{id}``
+ """
+ upstream: Final = (
+ next(
+ (
+ deployment["litellm_params"]["model"]
+ for deployment in (llm_router.get_model_list() or ())
+ if deployment.get("model_name") == setup_model
+ ),
+ None,
+ )
+ if llm_router is not None
+ else None
)
- if upstream is None:
- return setup_model
+ resolved: Final = upstream if upstream is not None else setup_model
+ without_publisher: Final = resolved.removeprefix(VERTEX_PUBLISHER_MODEL_PREFIX).removeprefix("models/")
try:
- _, provider, _, _ = litellm.get_llm_provider(model=upstream)
+ _, provider, _, _ = litellm.get_llm_provider(model=without_publisher)
except litellm.exceptions.BadRequestError:
- return upstream
- return upstream.removeprefix(f"{provider}/")
+ return without_publisher
+ return without_publisher.removeprefix(f"{provider}/")
async def vertex_ai_live_websocket_passthrough(
@@ -2500,9 +2507,7 @@
vertex_credentials_config.vertex_location if vertex_credentials_config is not None else None
)
credentials_value: Final = (
- str(vertex_credentials_config.vertex_credentials)
- if vertex_credentials_config is not None and vertex_credentials_config.vertex_credentials is not None
- else None
+ vertex_credentials_config.vertex_credentials if vertex_credentials_config is not None else None
)
try:
diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py
--- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py
+++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py
@@ -1,6 +1,8 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Final
+from pydantic import TypeAdapter
+
import litellm
from litellm._logging import verbose_router_logger
from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import (
@@ -27,6 +29,18 @@
return value if isinstance(value, str) else None
+def _get_vertex_credentials_value(
+ values: dict[str, object] | None,
+) -> VERTEX_CREDENTIALS_TYPES | None:
+ """Vertex service-account credentials can be stored as a JSON string or a parsed dict; keep either shape"""
+ value: Final = values.get("vertex_credentials") if values is not None else None
+ if isinstance(value, str):
+ return value
+ if isinstance(value, dict):
+ return TypeAdapter(dict[str, str]).validate_python(value)
+ return None
+
+
class PassthroughEndpointRouter:
"""
Use this class to Get credentials for pass-through endpoints
@@ -172,9 +186,9 @@
vertex_location: Final = _get_str_value(credential_values, "vertex_location") or litellm_params.get(
"vertex_location"
)
- vertex_credentials: Final = _get_str_value(credential_values, "vertex_credentials") or litellm_params.get(
- "vertex_credentials"
- )
+ vertex_credentials: Final = _get_vertex_credentials_value(
+ credential_values
+ ) or litellm_params.get("vertex_credentials")
if vertex_project is None or vertex_location is None:
return None
return VertexPassThroughCredentials(
diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py
--- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py
+++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py
@@ -5161,7 +5161,12 @@
@pytest.mark.asyncio
@pytest.mark.parametrize(
"setup_model",
- ["gemini-live-2.5-flash", "publishers/google/models/gemini-live-2.5-flash"],
+ [
+ "gemini-live-2.5-flash",
+ "publishers/google/models/gemini-live-2.5-flash",
+ "models/gemini-live-2.5-flash",
+ "vertex_ai/gemini-live-2.5-flash",
+ ],
)
async def test_websocket_passthrough_rewrites_setup_model_to_full_resource(setup_model):
sent_frame = await _run_setup_rewrite_passthrough(setup_model, llm_router=None)
diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py
--- a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py
+++ b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py
@@ -237,6 +237,56 @@
assert resolved.vertex_credentials == '{"type": "service_account", "project_id": "proj-inline"}'
+def test_vertex_deployment_preserves_dict_credentials_from_named_credential():
+ service_account = {"type": "service_account", "project_id": "proj-db"}
+ CredentialAccessor.upsert_credentials(
+ [
+ _vertex_credential(
+ "cred_gcp_dict",
+ {
+ "vertex_project": "proj-db",
+ "vertex_location": "global",
+ "vertex_credentials": service_account,
+ },
+ )
+ ]
+ )
+ llm_router = litellm.Router(
+ model_list=[
+ _vertex_deployment(
+ "gemini-live", "vertex_ai/gemini-live-2.5-flash", litellm_credential_name="cred_gcp_dict"
+ )
+ ]
+ )
+ passthrough_router = _passthrough_router(llm_router)
+
+ resolved = passthrough_router.get_vertex_credentials_from_router_deployments(model=None)
+
+ assert resolved is not None
+ assert resolved.vertex_credentials == service_account
+
+
+def test_vertex_deployment_preserves_dict_credentials_from_inline_litellm_params():
+ service_account = {"type": "service_account", "project_id": "proj-inline"}
+ llm_router = litellm.Router(
+ model_list=[
+ _vertex_deployment(
+ "gemini-live",
+ "vertex_ai/gemini-live-2.5-flash",
+ vertex_project="proj-inline",
+ vertex_location="us-east4",
+ vertex_credentials=service_account,
+ )
+ ]
+ )
+ passthrough_router = _passthrough_router(llm_router)
+
+ resolved = passthrough_router.get_vertex_credentials_from_router_deployments(model=None)
+
+ assert resolved is not None
+ assert resolved.vertex_credentials == service_account
+
+
def _two_vertex_deployments_router() -> litellm.Router:
return litellm.Router(
model_list=[You can send follow-ups to the cloud agent here.
…ct credentials - accept the Live SDK's models/<id> and LiteLLM's vertex_ai/<id> when rewriting the setup model - keep a dict service account intact instead of stringifying it - treat same-target deployments holding different credentials as ambiguous - guard both websocket states before every close so a second close cannot raise - build the sendable close codes from the public CloseCode enum
|
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 4f04e59. Configure here.

TLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer whose Live app connects through the gateway is dropped the moment the session starts, with nothing saying why
vertex_project,vertex_locationand the key JSON, and gets{"success":true}backlitellm_credential_nameanduse_in_pass_through: true, and gets 200 with the new model idsetupframe naming the model?vertex_project=and?vertex_location=to the URL, which the Live SDK never sendsAfter: the same app gets a real Live session on the credential the admin already saved, and anything Vertex turns down comes back in Google's own words
vertex_project,vertex_locationand the key JSON, and gets{"success":true}backlitellm_credential_nameanduse_in_pass_through: true, and gets 200 with the new model idsetupframe naming the modelsetupCompletewith a session id, and the app's first turn comes back as model text followed byturnCompletecarrying real token countsprojects/.../not-a-real-modelwas not found or ", so the mistake is obviousprojects/.../publishers/google/models/...path all work the same, with no query parameters on the URLRelevant issues
Linear ticket
Resolves LIT-5868
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, run once for both legs.
$PROJECTstands in for a real GCP project id andsa.jsonfor its service-account keydefault_vertex_config:GOOGLE_APPLICATION_CREDENTIALS,VERTEXAI_*orDEFAULT_VERTEXAI_*anywhere in its environment:base_urlat the gateway with no project or location puts it in the API-gateway mode it documents inlive.py: it takes the URL verbatim, sends the model name exactly as the caller typed it, and authenticates with the custom header. It only speakswss://, so a self-signed TLS terminator sits in front of the local proxy and the client trusts that certificate:setupframe with the model given on the command line, then one turn, and prints every frame or the close it gets back:The last case needs credential resolution to succeed so the failure comes from Vertex rather than the gateway, so it runs against a second config that adds the documented
default_vertex_config:Before (5290150)
Google Live SDK, the client the customer runs
PORT=38212 CAFILE=tls_cert.pem python adk_live_probe.py gemini-live-2.5-flashgrep -c "GCP global" proxy.logprints0: the stored credential is never consultedBare Vertex model id
python live_probe.py '{"model":"gemini-live-2.5-flash","generationConfig":{"responseModalities":["TEXT"]}}'Gateway model name
python live_probe.py '{"model":"gemini-live","generationConfig":{"responseModalities":["TEXT"]}}'Full Vertex resource path
python live_probe.py "{\"model\":\"projects/$PROJECT/locations/global/publishers/google/models/gemini-live-2.5-flash\",\"generationConfig\":{\"responseModalities\":[\"TEXT\"]}}"Model Vertex does not serve
config_b.yaml, so credentials resolve and the rejection comes from Googlepython live_probe.py "{\"model\":\"projects/$PROJECT/locations/global/publishers/google/models/not-a-real-model\",\"generationConfig\":{\"responseModalities\":[\"TEXT\"]}}"After (4f04e59)
Google Live SDK, the client the customer runs
PORT=38212 CAFILE=tls_cert.pem python adk_live_probe.py gemini-live-2.5-flashPORT=38212 CAFILE=tls_cert.pem python adk_live_probe.py gemini-livedoes the same on the gateway's own model name:Bare Vertex model id
python live_probe.py '{"model":"gemini-live-2.5-flash","generationConfig":{"responseModalities":["TEXT"]}}'Gateway model name
python live_probe.py '{"model":"gemini-live","generationConfig":{"responseModalities":["TEXT"]}}'Full Vertex resource path
python live_probe.py "{\"model\":\"projects/$PROJECT/locations/global/publishers/google/models/gemini-live-2.5-flash\",\"generationConfig\":{\"responseModalities\":[\"TEXT\"]}}"Model Vertex does not serve
config_b.yaml, so credentials resolve and the rejection comes from Googlepython live_probe.py "{\"model\":\"projects/$PROJECT/locations/global/publishers/google/models/not-a-real-model\",\"generationConfig\":{\"responseModalities\":[\"TEXT\"]}}"Type
🐛 Bug Fix
Caveats (if any)
default_vertex_configand theDEFAULT_VERTEXAI_*env vars still outrank the DB entries, so a global default keeps winning where one is set?model=,default_vertex_config, or one project, location, and credential across the pass-through deployments, since it will not guess between themFinal 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
4f04e59 passes /live-pr-risk