fix(vertex_ai): improve passthrough endpoint url parsing and construction (#17402) (#17526)

* fix(vertex_ai): improve passthrough endpoint url parsing and construction (#17402)

* test(proxy): add test for vertex passthrough load balancing

Add a test that verifies _base_vertex_proxy_route uses
get_available_deployment for proper load balancing instead of
get_model_list. This ensures the correct deployment is selected
from the router and vertex credentials are properly fetched.

Also refactor the implementation to:
- Use get_available_deployment instead of get_model_list
- Add error handling for deployment retrieval
- Improve code structure with try-except block

* feat(proxy): add pass-through deployment filtering methods

Add dedicated methods to filter and select deployments for pass-through endpoints:
- Implement get_available_deployment_for_pass_through() to ensure only deployments with use_in_pass_through=True are considered
- Implement async_get_available_deployment_for_pass_through() for async operations
- Add _filter_pass_through_deployments() helper method to filter by use_in_pass_through flag
- Update vertex pass-through route to use the new dedicated method

This ensures pass-through endpoints respect the use_in_pass_through configuration and apply proper load balancing strategy only to configured deployments.

Add comprehensive tests to verify filtering and load balancing behavior.
This commit is contained in:
Kris Xia
2026-01-14 22:17:43 +05:30
committed by GitHub
parent e8c4cad885
commit 1391e41916
5 changed files with 650 additions and 7 deletions
+19
View File
@@ -771,6 +771,16 @@ def get_vertex_location_from_url(url: str) -> Optional[str]:
return match.group(1) if match else None
def get_vertex_model_id_from_url(url: str) -> Optional[str]:
"""
Get the vertex model id from the url
`https://${LOCATION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION}/publishers/google/models/${MODEL_ID}:streamGenerateContent`
"""
match = re.search(r"/models/([^/:]+)", url)
return match.group(1) if match else None
def replace_project_and_location_in_route(
requested_route: str, vertex_project: str, vertex_location: str
) -> str:
@@ -820,6 +830,15 @@ def construct_target_url(
if "cachedContent" in requested_route:
vertex_version = "v1beta1"
# Check if the requested route starts with a version
# e.g. /v1beta1/publishers/google/models/gemini-3-pro-preview:streamGenerateContent
if requested_route.startswith("/v1/"):
vertex_version = "v1"
requested_route = requested_route.replace("/v1/", "/", 1)
elif requested_route.startswith("/v1beta1/"):
vertex_version = "v1beta1"
requested_route = requested_route.replace("/v1beta1/", "/", 1)
base_requested_route = "{}/projects/{}/locations/{}".format(
vertex_version, vertex_project, vertex_location
)
@@ -1555,6 +1555,7 @@ async def _base_vertex_proxy_route(
from litellm.llms.vertex_ai.common_utils import (
construct_target_url,
get_vertex_location_from_url,
get_vertex_model_id_from_url,
get_vertex_project_id_from_url,
)
@@ -1584,6 +1585,25 @@ async def _base_vertex_proxy_route(
vertex_location=vertex_location,
)
if vertex_project is None or vertex_location is None:
# Check if model is in router config
model_id = get_vertex_model_id_from_url(endpoint)
if model_id:
from litellm.proxy.proxy_server import llm_router
if llm_router:
try:
# Use the dedicated pass-through deployment selection method to automatically filter use_in_pass_through=True
deployment = llm_router.get_available_deployment_for_pass_through(model=model_id)
if deployment:
litellm_params = deployment.get("litellm_params", {})
vertex_project = litellm_params.get("vertex_project")
vertex_location = litellm_params.get("vertex_location")
except Exception as e:
verbose_proxy_logger.debug(
f"Error getting available deployment for model {model_id}: {e}"
)
vertex_credentials = passthrough_endpoint_router.get_vertex_credentials(
project_id=vertex_project,
location=vertex_location,
+339
View File
@@ -7994,6 +7994,154 @@ class Router:
)
raise e
async def async_get_available_deployment_for_pass_through(
self,
model: str,
request_kwargs: Dict,
messages: Optional[List[Dict[str, str]]] = None,
input: Optional[Union[str, List]] = None,
specific_deployment: Optional[bool] = False,
):
"""
Async version of get_available_deployment_for_pass_through
Only returns deployments configured with use_in_pass_through=True
"""
try:
parent_otel_span = _get_parent_otel_span_from_kwargs(request_kwargs)
# 1. Execute pre-routing hook
pre_routing_hook_response = await self.async_pre_routing_hook(
model=model,
request_kwargs=request_kwargs,
messages=messages,
input=input,
specific_deployment=specific_deployment,
)
if pre_routing_hook_response is not None:
model = pre_routing_hook_response.model
messages = pre_routing_hook_response.messages
# 2. Get healthy deployments
healthy_deployments = await self.async_get_healthy_deployments(
model=model,
request_kwargs=request_kwargs,
messages=messages,
input=input,
specific_deployment=specific_deployment,
parent_otel_span=parent_otel_span,
)
# 3. If specific deployment returned, verify if it supports pass-through
if isinstance(healthy_deployments, dict):
litellm_params = healthy_deployments.get("litellm_params", {})
if litellm_params.get("use_in_pass_through"):
return healthy_deployments
else:
raise litellm.BadRequestError(
message=f"Deployment {healthy_deployments.get('model_info', {}).get('id')} does not support pass-through endpoint (use_in_pass_through=False)",
model=model,
llm_provider="",
)
# 4. Filter deployments that support pass-through
pass_through_deployments = self._filter_pass_through_deployments(
healthy_deployments=healthy_deployments
)
if len(pass_through_deployments) == 0:
raise litellm.BadRequestError(
message=f"Model {model} has no deployments configured with use_in_pass_through=True. Please add use_in_pass_through: true to the deployment configuration",
model=model,
llm_provider="",
)
# 5. Apply load balancing strategy
start_time = time.perf_counter()
if (
self.routing_strategy == "usage-based-routing-v2"
and self.lowesttpm_logger_v2 is not None
):
deployment = (
await self.lowesttpm_logger_v2.async_get_available_deployments(
model_group=model,
healthy_deployments=pass_through_deployments, # type: ignore
messages=messages,
input=input,
)
)
elif (
self.routing_strategy == "latency-based-routing"
and self.lowestlatency_logger is not None
):
deployment = (
await self.lowestlatency_logger.async_get_available_deployments(
model_group=model,
healthy_deployments=pass_through_deployments, # type: ignore
messages=messages,
input=input,
request_kwargs=request_kwargs,
)
)
elif self.routing_strategy == "simple-shuffle":
return simple_shuffle(
llm_router_instance=self,
healthy_deployments=pass_through_deployments,
model=model,
)
elif (
self.routing_strategy == "least-busy"
and self.leastbusy_logger is not None
):
deployment = (
await self.leastbusy_logger.async_get_available_deployments(
model_group=model,
healthy_deployments=pass_through_deployments, # type: ignore
)
)
else:
deployment = None
if deployment is None:
exception = await async_raise_no_deployment_exception(
litellm_router_instance=self,
model=model,
parent_otel_span=parent_otel_span,
)
raise exception
verbose_router_logger.info(
f"async_get_available_deployment_for_pass_through model: {model}, selected deployment: {self.print_deployment(deployment)}"
)
end_time = time.perf_counter()
_duration = end_time - start_time
asyncio.create_task(
self.service_logger_obj.async_service_success_hook(
service=ServiceTypes.ROUTER,
duration=_duration,
call_type="<routing_strategy>.async_get_available_deployments",
parent_otel_span=parent_otel_span,
start_time=start_time,
end_time=end_time,
)
)
return deployment
except Exception as e:
traceback_exception = traceback.format_exc()
if request_kwargs is not None:
logging_obj = request_kwargs.get("litellm_logging_obj", None)
if logging_obj is not None:
threading.Thread(
target=logging_obj.failure_handler,
args=(e, traceback_exception),
).start()
asyncio.create_task(
logging_obj.async_failure_handler(e, traceback_exception) # type: ignore
)
raise e
async def async_pre_routing_hook(
self,
model: str,
@@ -8146,6 +8294,169 @@ class Router:
)
return deployment
def get_available_deployment_for_pass_through(
self,
model: str,
messages: Optional[List[Dict[str, str]]] = None,
input: Optional[Union[str, List]] = None,
specific_deployment: Optional[bool] = False,
request_kwargs: Optional[Dict] = None,
):
"""
Returns deployments available for pass-through endpoints (based on load balancing strategy)
Similar to get_available_deployment, but only returns deployments with use_in_pass_through=True
Args:
model: Model name
messages: Optional list of messages
input: Optional input data
specific_deployment: Whether to find a specific deployment
request_kwargs: Optional request parameters
Returns:
Dict: Selected deployment configuration
Raises:
BadRequestError: If no deployment is configured with use_in_pass_through=True
RouterRateLimitError: If no pass-through deployments are available
"""
# 1. Perform common checks to get healthy deployments list
model, healthy_deployments = self._common_checks_available_deployment(
model=model,
messages=messages,
input=input,
specific_deployment=specific_deployment,
)
# 2. If the returned is a specific deployment (Dict), verify and return directly
if isinstance(healthy_deployments, dict):
litellm_params = healthy_deployments.get("litellm_params", {})
if litellm_params.get("use_in_pass_through"):
return healthy_deployments
else:
# Specific deployment does not support pass-through
raise litellm.BadRequestError(
message=f"Deployment {healthy_deployments.get('model_info', {}).get('id')} does not support pass-through endpoint (use_in_pass_through=False)",
model=model,
llm_provider="",
)
# 3. Filter deployments that support pass-through
pass_through_deployments = self._filter_pass_through_deployments(
healthy_deployments=healthy_deployments
)
if len(pass_through_deployments) == 0:
# No deployments support pass-through
raise litellm.BadRequestError(
message=f"Model {model} has no deployment configured with use_in_pass_through=True. Please add use_in_pass_through: true in the deployment configuration",
model=model,
llm_provider="",
)
# 4. Apply cooldown filtering
parent_otel_span: Optional[Span] = _get_parent_otel_span_from_kwargs(
request_kwargs
)
cooldown_deployments = _get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
pass_through_deployments = self._filter_cooldown_deployments(
healthy_deployments=pass_through_deployments,
cooldown_deployments=cooldown_deployments,
)
# 5. Apply pre-call checks (if enabled)
if self.enable_pre_call_checks and messages is not None:
pass_through_deployments = self._pre_call_checks(
model=model,
healthy_deployments=pass_through_deployments,
messages=messages,
request_kwargs=request_kwargs,
)
if len(pass_through_deployments) == 0:
model_ids = self.get_model_ids(model_name=model)
_cooldown_time = self.cooldown_cache.get_min_cooldown(
model_ids=model_ids, parent_otel_span=parent_otel_span
)
_cooldown_list = _get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
raise RouterRateLimitError(
model=model,
cooldown_time=_cooldown_time,
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
)
# 6. Apply load balancing strategy
if self.routing_strategy == "least-busy" and self.leastbusy_logger is not None:
deployment = self.leastbusy_logger.get_available_deployments(
model_group=model, healthy_deployments=pass_through_deployments # type: ignore
)
elif self.routing_strategy == "simple-shuffle":
return simple_shuffle(
llm_router_instance=self,
healthy_deployments=pass_through_deployments,
model=model,
)
elif (
self.routing_strategy == "latency-based-routing"
and self.lowestlatency_logger is not None
):
deployment = self.lowestlatency_logger.get_available_deployments(
model_group=model,
healthy_deployments=pass_through_deployments, # type: ignore
request_kwargs=request_kwargs,
)
elif (
self.routing_strategy == "usage-based-routing"
and self.lowesttpm_logger is not None
):
deployment = self.lowesttpm_logger.get_available_deployments(
model_group=model,
healthy_deployments=pass_through_deployments, # type: ignore
messages=messages,
input=input,
)
elif (
self.routing_strategy == "usage-based-routing-v2"
and self.lowesttpm_logger_v2 is not None
):
deployment = self.lowesttpm_logger_v2.get_available_deployments(
model_group=model,
healthy_deployments=pass_through_deployments, # type: ignore
messages=messages,
input=input,
)
else:
deployment = None
if deployment is None:
verbose_router_logger.info(
f"get_available_deployment_for_pass_through model: {model}, no available deployments"
)
model_ids = self.get_model_ids(model_name=model)
_cooldown_time = self.cooldown_cache.get_min_cooldown(
model_ids=model_ids, parent_otel_span=parent_otel_span
)
_cooldown_list = _get_cooldown_deployments(
litellm_router_instance=self, parent_otel_span=parent_otel_span
)
raise RouterRateLimitError(
model=model,
cooldown_time=_cooldown_time,
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
)
verbose_router_logger.info(
f"get_available_deployment_for_pass_through model: {model}, selected deployment: {self.print_deployment(deployment)}"
)
return deployment
def _filter_cooldown_deployments(
self, healthy_deployments: List[Dict], cooldown_deployments: List[str]
) -> List[Dict]:
@@ -8168,6 +8479,34 @@ class Router:
if deployment["model_info"]["id"] not in cooldown_set
]
def _filter_pass_through_deployments(
self, healthy_deployments: List[Dict]
) -> List[Dict]:
"""
Filter out deployments configured with use_in_pass_through=True
Args:
healthy_deployments: List of healthy deployments
Returns:
List[Dict]: Only includes a list of deployments that support pass-through
"""
verbose_router_logger.debug(
f"Filter pass-through deployments from {len(healthy_deployments)} healthy deployments"
)
pass_through_deployments = [
deployment
for deployment in healthy_deployments
if deployment.get("litellm_params", {}).get("use_in_pass_through", False)
]
verbose_router_logger.debug(
f"Found {len(pass_through_deployments)} deployments with pass-through enabled"
)
return pass_through_deployments
def _track_deployment_metrics(
self, deployment, parent_otel_span: Optional[Span], response=None
):
@@ -1,7 +1,6 @@
import os
import sys
from typing import Any, Dict
from unittest.mock import MagicMock, call, patch
from unittest.mock import patch
import pytest
@@ -11,7 +10,6 @@ sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
import litellm
from litellm.llms.vertex_ai.common_utils import (
_get_vertex_url,
convert_anyof_null_to_nullable,
@@ -798,9 +796,54 @@ def test_fix_enum_empty_strings():
assert "mobile" in enum_values
assert "tablet" in enum_values
# 3. Other properties preserved
assert input_schema["properties"]["user_agent_type"]["type"] == "string"
assert input_schema["properties"]["user_agent_type"]["description"] == "Device type for user agent"
def test_get_vertex_model_id_from_url():
"""Test get_vertex_model_id_from_url with various URLs"""
from litellm.llms.vertex_ai.common_utils import get_vertex_model_id_from_url
# Test with valid URL
url = "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-pro:streamGenerateContent"
model_id = get_vertex_model_id_from_url(url)
assert model_id == "gemini-pro"
# Test with invalid URL
url = "https://invalid-url.com"
model_id = get_vertex_model_id_from_url(url)
assert model_id is None
def test_construct_target_url_with_version_prefix():
"""Test construct_target_url with version prefixes"""
from litellm.llms.vertex_ai.common_utils import construct_target_url
# Test with /v1/ prefix
url = "/v1/publishers/google/models/gemini-pro:streamGenerateContent"
vertex_project = "test-project"
vertex_location = "us-central1"
base_url = "https://us-central1-aiplatform.googleapis.com"
target_url = construct_target_url(
base_url=base_url,
requested_route=url,
vertex_project=vertex_project,
vertex_location=vertex_location,
)
expected_url = "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-pro:streamGenerateContent"
assert str(target_url) == expected_url
# Test with /v1beta1/ prefix
url = "/v1beta1/publishers/google/models/gemini-pro:streamGenerateContent"
target_url = construct_target_url(
base_url=base_url,
requested_route=url,
vertex_project=vertex_project,
vertex_location=vertex_location,
)
expected_url = "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/us-central1/publishers/google/models/gemini-pro:streamGenerateContent"
assert str(target_url) == expected_url
def test_fix_enum_types():
@@ -862,7 +905,7 @@ def test_fix_enum_types():
"truncateMode": {
"enum": ["auto", "none", "start", "end"], # Kept - string type
"type": "string",
"description": "How to truncate content"
"description": "How to truncate content",
},
"maxLength": { # enum removed
"type": "integer",
@@ -0,0 +1,222 @@
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import _base_vertex_proxy_route
from litellm.types.router import DeploymentTypedDict
@pytest.mark.asyncio
async def test_vertex_passthrough_load_balancing():
"""
Test that _base_vertex_proxy_route uses llm_router.get_available_deployment_for_pass_through
instead of get_model_list to ensure load balancing works with pass-through filtering.
"""
# Setup mocks
mock_request = MagicMock()
mock_response = MagicMock()
mock_handler = MagicMock()
# Mock the router
mock_router = MagicMock()
mock_deployment = {
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "test-project-lb",
"vertex_location": "us-central1-lb",
"use_in_pass_through": True
}
}
mock_router.get_available_deployment_for_pass_through.return_value = mock_deployment
# Mock get_vertex_model_id_from_url to return a model ID
with patch("litellm.llms.vertex_ai.common_utils.get_vertex_model_id_from_url", return_value="gemini-pro"), \
patch("litellm.proxy.proxy_server.llm_router", mock_router), \
patch("litellm.llms.vertex_ai.common_utils.get_vertex_project_id_from_url", return_value=None), \
patch("litellm.llms.vertex_ai.common_utils.get_vertex_location_from_url", return_value=None), \
patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router") as mock_pt_router, \
patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints._prepare_vertex_auth_headers", new_callable=AsyncMock) as mock_prep_headers, \
patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route") as mock_create_route, \
patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.user_api_key_auth", new_callable=AsyncMock) as mock_auth:
# Setup additional mocks to avoid side effects
mock_pt_router.get_vertex_credentials.return_value = MagicMock()
mock_prep_headers.return_value = ({}, "https://test.url", False, "test-project-lb", "us-central1-lb")
mock_endpoint_func = AsyncMock()
mock_create_route.return_value = mock_endpoint_func
mock_auth.return_value = {}
# Execute
await _base_vertex_proxy_route(
endpoint="https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-pro:streamGenerateContent",
request=mock_request,
fastapi_response=mock_response,
get_vertex_pass_through_handler=mock_handler
)
# Verify
# 1. Check that get_available_deployment_for_pass_through was called with the correct model ID
mock_router.get_available_deployment_for_pass_through.assert_called_once_with(model="gemini-pro")
# 2. Check that get_model_list was NOT called (this ensures we aren't doing the old logic)
mock_router.get_model_list.assert_not_called()
# 3. Verify that the project and location from the deployment were used (passed to _prepare_vertex_auth_headers)
# The args are: request, vertex_credentials, router_credentials, vertex_project, vertex_location, ...
# We check the 4th and 5th args (index 3 and 4)
call_args = mock_prep_headers.call_args
assert call_args[1]['vertex_project'] == "test-project-lb"
assert call_args[1]['vertex_location'] == "us-central1-lb"
def test_get_available_deployment_for_pass_through_filters_correctly():
"""
Test that get_available_deployment_for_pass_through filters deployments correctly
"""
from litellm.router import Router
# Configure router with both pass-through and non-pass-through deployments
model_list = [
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-1",
"vertex_location": "us-central1",
"use_in_pass_through": True, # Supports pass-through
}
},
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-2",
"vertex_location": "us-west1",
"use_in_pass_through": False, # Does not support pass-through
}
},
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-3",
"vertex_location": "us-east1",
# use_in_pass_through not set (defaults to False)
}
},
]
router = Router(model_list=model_list, routing_strategy="simple-shuffle")
# Test: Should only return project-1 (use_in_pass_through=True)
deployment = router.get_available_deployment_for_pass_through(model="gemini-pro")
assert deployment is not None
assert deployment["litellm_params"]["vertex_project"] == "project-1"
assert deployment["litellm_params"]["use_in_pass_through"] is True
def test_get_available_deployment_for_pass_through_no_deployments():
"""
Test that correct error is thrown when there are no pass-through deployments
"""
import litellm
from litellm.router import Router
model_list = [
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-1",
"vertex_location": "us-central1",
"use_in_pass_through": False, # Does not support pass-through
}
}
]
router = Router(model_list=model_list)
# Should throw BadRequestError
with pytest.raises(litellm.BadRequestError) as exc_info:
router.get_available_deployment_for_pass_through(model="gemini-pro")
assert "use_in_pass_through=True" in str(exc_info.value)
def test_get_available_deployment_for_pass_through_load_balancing():
"""
Test load balancing for pass-through deployments
"""
from litellm.router import Router
model_list = [
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-1",
"vertex_location": "us-central1",
"use_in_pass_through": True,
"rpm": 100,
}
},
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-2",
"vertex_location": "us-west1",
"use_in_pass_through": True,
"rpm": 200, # Higher RPM should be selected more frequently
}
},
]
router = Router(
model_list=model_list,
routing_strategy="simple-shuffle"
)
# Call multiple times and track selected deployments
selections = {"project-1": 0, "project-2": 0}
for _ in range(100):
deployment = router.get_available_deployment_for_pass_through(model="gemini-pro")
project = deployment["litellm_params"]["vertex_project"]
selections[project] += 1
# Due to rpm weight, project-2 should be selected more times
assert selections["project-2"] > selections["project-1"]
@pytest.mark.asyncio
async def test_async_get_available_deployment_for_pass_through():
"""
Test the async version of get_available_deployment_for_pass_through
"""
from litellm.router import Router
model_list = [
{
"model_name": "gemini-pro",
"litellm_params": {
"model": "vertex_ai/gemini-pro",
"vertex_project": "project-1",
"vertex_location": "us-central1",
"use_in_pass_through": True,
}
}
]
router = Router(
model_list=model_list,
routing_strategy="simple-shuffle"
)
deployment = await router.async_get_available_deployment_for_pass_through(
model="gemini-pro",
request_kwargs={}
)
assert deployment is not None
assert deployment["litellm_params"]["use_in_pass_through"] is True