fix(passthrough): bound azure relays to the key's model group and reject foreign deployment segments

This commit is contained in:
mateo-berri 2026-09-07 21:54:37 -07:00
parent 02b18b4fc6
commit 1de369a1a4
6 changed files with 217 additions and 8 deletions

View file

@ -1985,9 +1985,19 @@ def get_model_from_request(
bedrock_model: Final = _model_from_bedrock_route(route)
return model if bedrock_model is None else bedrock_model
if route.lower().startswith(("/azure/", "/azure_ai/")):
azure_model: Final = _router_model_from_azure_route(route, llm_router)
return model if azure_model is None else azure_model
return model
def _router_model_from_azure_route(route: str, llm_router: Router | None) -> str | None:
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import azure_router_model_in_endpoint
return azure_router_model_in_endpoint(re.sub(r"^/azure(?:_ai)?/", "", route, flags=re.IGNORECASE), llm_router)
def _model_from_bedrock_route(route: str) -> str | None:
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
_extract_model_from_bedrock_endpoint,

View file

@ -78,6 +78,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
)
from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials
from litellm.types.router import LiteLLMParamsTypedDict
from litellm.types.utils import LlmProviders
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
from litellm.utils import ProviderConfigManager
@ -120,6 +121,37 @@ def is_passthrough_request_using_router_model(request_body: dict, llm_router: li
return False
def azure_router_model_in_endpoint(endpoint: str, llm_router: litellm.Router | None) -> str | None:
parts: Final = endpoint.split("/")
if len(parts) < 2:
return None
return next((part for part in parts if is_known_model(part, llm_router)), None)
AZURE_DEPLOYMENT_SEGMENT: Final = re.compile(r"(?<![^/])openai/deployments/([^/]+)")
def _deployment_model_name(litellm_params: LiteLLMParamsTypedDict) -> str:
model: Final = litellm_params.get("model", "")
try:
return get_llm_provider(model=model, custom_llm_provider=litellm_params.get("custom_llm_provider"))[0]
except litellm.BadRequestError:
return model
def foreign_azure_deployment(endpoint: str, model_group: str, llm_router: litellm.Router) -> str | None:
match: Final = AZURE_DEPLOYMENT_SEGMENT.search(endpoint)
if match is None:
return None
deployment: Final = match.group(1)
if deployment == model_group:
return None
served: Final = frozenset(
_deployment_model_name(row["litellm_params"]) for row in llm_router.get_model_list(model_name=model_group) or ()
)
return None if deployment in served else deployment
def is_passthrough_request_streaming(request_body: object) -> bool:
"""
Returns True if the request is streaming.
@ -1523,6 +1555,15 @@ async def _relay_azure_router_model(
is_streaming_request: bool,
user_api_key_dict: UserAPIKeyAuth,
) -> Response:
foreign_deployment: Final = foreign_azure_deployment(endpoint, model, llm_router)
if foreign_deployment is not None:
raise HTTPException(
status_code=400,
detail={
"error": f"deployment '{foreign_deployment}' in the path is not served by model group '{model}'; "
"put the model group name in the deployments segment"
},
)
try:
result: Final = await llm_router.allm_passthrough_route(
model=model,

View file

@ -334,12 +334,12 @@ def test_azure_passthrough_url_rewrites_the_model_group_only_as_a_whole_segment(
api_base="https://my-resource.openai.azure.com",
api_key="key",
model="gpt-4.1-mini",
endpoint="gpt/openai/deployments/gpt-4o/chat/completions",
endpoint="gpt/openai/deployments/gpt-4.1-mini/chat/completions",
request_query_params={"api-version": "2024-10-21"},
litellm_params={"litellm_metadata": {"model_group": "gpt"}},
)
assert str(url) == "https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
assert str(url) == "https://my-resource.openai.azure.com/openai/deployments/gpt-4.1-mini/chat/completions?api-version=2024-10-21"
@pytest.mark.parametrize(

View file

@ -528,6 +528,38 @@ def test_get_model_from_request_bedrock_unparseable_endpoint_keeps_body_model():
)
def _azure_relay_router():
from litellm.router import Router
return Router(
model_list=[
{
"model_name": "gpt",
"litellm_params": {"model": "azure_ai/gpt-5.4-mini", "api_base": "https://a.services.ai.azure.com", "api_key": "k"},
},
{
"model_name": "other-group",
"litellm_params": {"model": "azure/gpt-5.4", "api_base": "https://b.openai.azure.com", "api_key": "k"},
},
]
)
@pytest.mark.parametrize(
"route, request_data, expected",
[
("/azure_ai/other-group/openai/deployments/other-group/chat/completions", {"model": "gpt"}, "other-group"),
("/azure_ai/other-group/models/chat/completions", {}, "other-group"),
("/azure/openai/deployments/gpt/chat/completions", {"model": "other-group"}, "gpt"),
("/azure/openai/deployments/gpt/chat/completions", {}, "gpt"),
("/azure/openai/deployments/my-azure-deployment/chat/completions", {"model": "gpt"}, "gpt"),
("/azure_ai/gpt", {"model": "other-group"}, "other-group"),
],
)
def test_get_model_from_request_azure_relay_routes_use_the_model_group_in_the_path(route, request_data, expected):
assert get_model_from_request(request_data=request_data, route=route, llm_router=_azure_relay_router()) == expected
def test_get_model_from_request_includes_file_endpoint_header_model():
assert (
get_model_from_request(

View file

@ -4999,7 +4999,11 @@ class TestPassthroughRouterModelBudgetReservation:
monkeypatch.setattr(proxy_server, "llm_router", RecordingRouter())
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True)
monkeypatch.setattr(
ep,
"is_passthrough_request_using_router_model",
lambda request_body, llm_router=None: request_body.get("model") in ("gpt-5", "router-model"),
)
return captured
def _assert_metadata_carries_attribution(self, captured: list[dict], user_api_key_dict: UserAPIKeyAuth) -> None:
@ -5098,7 +5102,11 @@ class TestAzureRouterModelStreamingDispatch:
monkeypatch.setattr(proxy_server, "llm_router", StreamingRouter())
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True)
monkeypatch.setattr(
ep,
"is_passthrough_request_using_router_model",
lambda request_body, llm_router=None: request_body.get("model") in ("gpt-5", "router-model"),
)
request = MagicMock(spec=Request)
request.method = "POST"
@ -5158,7 +5166,11 @@ class TestAzureRouterModelStreamingKeepalive:
monkeypatch.setattr(proxy_server, "llm_router", StreamingRouter())
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True)
monkeypatch.setattr(
ep,
"is_passthrough_request_using_router_model",
lambda request_body, llm_router=None: request_body.get("model") in ("gpt-5", "router-model"),
)
request = MagicMock(spec=Request)
request.method = "POST"
@ -5225,7 +5237,11 @@ class TestRouterModelRelayUpstreamContract:
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True)
monkeypatch.setattr(
ep,
"is_passthrough_request_using_router_model",
lambda request_body, llm_router=None: request_body.get("model") in ("gpt-5", "router-model"),
)
def _recording_router(self, captured: list[dict]):
class RecordingRouter:
@ -5327,3 +5343,113 @@ async def test_bedrock_count_tokens_error_forwards_provider_headers():
assert exc_info.value.status_code == 500
assert exc_info.value.headers["llm_provider-x-amzn-requestid"] == "req-count-tokens-500"
class _AzureGroupRouter:
def __init__(self, captured: list[dict]) -> None:
self.captured = captured
def get_model_names(self, team_id=None):
return ["gpt", "other-group"]
def get_model_list(self, model_name=None, team_id=None):
rows = [
{"model_name": "gpt", "litellm_params": {"model": "azure_ai/gpt-5.4-mini", "api_key": "k"}},
{"model_name": "other-group", "litellm_params": {"model": "azure/gpt-5.4", "api_key": "k"}},
]
return [row for row in rows if model_name is None or row["model_name"] == model_name]
async def allm_passthrough_route(self, **kwargs):
self.captured.append(kwargs)
return httpx.Response(200, json={"ok": True})
class TestAzureRelayDeploymentSegment:
"""A key allowed one model group must not reach another deployment by naming it in the
``openai/deployments/<x>`` segment while the group segment picks the credential."""
@pytest.mark.parametrize(
"endpoint, expected",
[
("gpt/openai/deployments/gpt/chat/completions", None),
("openai/deployments/gpt/chat/completions", None),
("gpt/openai/deployments/gpt-5.4-mini/chat/completions", None),
("gpt/models/chat/completions", None),
("gpt/openai/deployments/gpt-5.4/chat/completions", "gpt-5.4"),
("gpt/openai/deployments/other-group/chat/completions", "other-group"),
("openai/deployments/victim/gpt/chat/completions", "victim"),
],
)
def test_foreign_azure_deployment_names_a_segment_outside_the_group(self, endpoint, expected):
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import foreign_azure_deployment
assert foreign_azure_deployment(endpoint, "gpt", _AzureGroupRouter([])) == expected
@pytest.mark.parametrize(
"endpoint, expected",
[
("other-group/openai/deployments/other-group/chat/completions", "other-group"),
("openai/deployments/gpt/chat/completions", "gpt"),
("openai/deployments/my-azure-deployment/chat/completions", None),
("gpt", None),
],
)
def test_azure_router_model_in_endpoint_matches_the_relay_decision(self, endpoint, expected):
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import azure_router_model_in_endpoint
assert azure_router_model_in_endpoint(endpoint, _AzureGroupRouter([])) == expected
def _install(self, monkeypatch, body: dict) -> list[dict]:
import litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints as ep
import litellm.proxy.proxy_server as proxy_server
captured: list[dict] = []
async def fake_get_request_body(_request):
return body
monkeypatch.setattr(proxy_server, "llm_router", _AzureGroupRouter(captured))
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
return captured
def _request(self) -> Request:
request = MagicMock(spec=Request)
request.method = "POST"
request.headers = {"content-type": "application/json"}
request.query_params = {}
return request
@pytest.mark.asyncio
async def test_azure_relay_rejects_a_deployment_the_group_does_not_serve(self, monkeypatch):
from fastapi import HTTPException
captured = self._install(monkeypatch, {"model": "gpt", "messages": []})
with pytest.raises(HTTPException) as exc_info:
await azure_proxy_route(
endpoint="gpt/openai/deployments/gpt-5.4/chat/completions",
request=self._request(),
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-token", models=["gpt"]),
)
assert exc_info.value.status_code == 400
assert "gpt-5.4" in exc_info.value.detail["error"]
assert captured == []
@pytest.mark.asyncio
async def test_azure_relay_dispatches_the_group_and_its_own_deployment_name(self, monkeypatch):
captured = self._install(monkeypatch, {"model": "gpt", "messages": []})
for endpoint in (
"gpt/openai/deployments/gpt/chat/completions",
"gpt/openai/deployments/gpt-5.4-mini/chat/completions",
):
await azure_proxy_route(
endpoint=endpoint,
request=self._request(),
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-token", models=["gpt"]),
)
assert [call["model"] for call in captured] == ["gpt", "gpt"]

View file

@ -5053,8 +5053,8 @@ def test_get_deployment_model_info_base_model_merge_priority():
(
"gpt",
{"model": "azure_ai/gpt-5.4-mini", "api_base": "https://my-resource.services.ai.azure.com", "api_key": "key"},
"gpt/openai/deployments/gpt-4o/chat/completions",
"gpt-5.4-mini/openai/deployments/gpt-4o/chat/completions",
"gpt/openai/deployments/gpt-5.4-mini/chat/completions",
"gpt-5.4-mini/openai/deployments/gpt-5.4-mini/chat/completions",
),
(
"aws/anthropic/bedrock-claude",