mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(passthrough): bound azure relays to the key's model group and reject foreign deployment segments
This commit is contained in:
parent
02b18b4fc6
commit
1de369a1a4
6 changed files with 217 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue