Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/proxy/auth/auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ async def common_checks(
request_headers=_safe_get_request_headers(request=request),
request_query_params=_safe_get_request_query_params(request=request),
llm_router=llm_router,
request=request,
)

if route in MODEL_DISCOVERY_ROUTES:
Expand Down
40 changes: 40 additions & 0 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.proxy._types import *
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
)
from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS
from litellm.types.utils import CustomPricingLiteLLMParams

Expand Down Expand Up @@ -1482,13 +1485,50 @@ def _format_model_candidates(
return candidates


def _request_dispatched_to_pass_through_endpoint(request: Request | None) -> bool:
"""Whether FastAPI resolved this request to a user-defined pass-through handler.

Reads the marker set by ``create_pass_through_route`` off the dispatched endpoint
(``request.scope["endpoint"]``). Because routing has already run by the time auth
dependencies execute, this reflects the handler that actually serves the request:
a custom path colliding with a built-in route resolves to the built-in handler,
which carries no marker, so model-access checks are never wrongly skipped.
"""
if request is None:
return False
scope = getattr(request, "scope", None)
if not isinstance(scope, dict):
return False
endpoint = scope.get("endpoint")
# Identity check against True (not truthiness): the marker is set to the literal
# True, and this keeps a spec'd Mock request (whose attribute access yields truthy
# child mocks) from being misread as a pass-through dispatch.
return getattr(endpoint, LITELLM_PASS_THROUGH_ENDPOINT_MARKER, False) is True


def get_model_from_request(
request_data: dict,
route: str,
request_headers: Optional[Mapping[str, Any]] = None,
request_query_params: Optional[Mapping[str, Any]] = None,
llm_router: Optional[Router] = None,
request: Request | None = None,
) -> Optional[Union[str, List[str]]]:
"""Resolve the model(s) a request targets, for model-access and budget checks.

Returns ``None`` when the request was dispatched to a user-defined pass-through
endpoint: its body is forwarded verbatim to the configured upstream, so a
``model`` field there names an upstream model, not a LiteLLM-managed one, and
enforcing key/team model allowlists against it would reject valid requests. The
check reads the FastAPI-resolved endpoint (``request.scope["endpoint"]``), not the
request path, so a custom path that collides with a built-in route never
suppresses model-access checks: on a collision the built-in handler is dispatched
and does not carry the marker. Built-in provider passthrough routes
(``/vertex_ai``, ``/gemini``, ...) are separate handlers and keep model enforcement.
"""
if _request_dispatched_to_pass_through_endpoint(request):
return None

candidates = _extract_model_candidates_from_request(
request_data=request_data,
route=route,
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def _get_model_from_request_context(
request_headers=_safe_get_request_headers(request=request),
request_query_params=_safe_get_request_query_params(request=request),
llm_router=llm_router,
request=request,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
EndpointType,
PassthroughStandardLoggingPayload,
Expand Down Expand Up @@ -1771,6 +1772,7 @@ async def endpoint_func( # type: ignore
if hasattr(request.state, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY):
delattr(request.state, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY)

setattr(endpoint_func, LITELLM_PASS_THROUGH_ENDPOINT_MARKER, True)
return endpoint_func


Expand Down
8 changes: 8 additions & 0 deletions litellm/types/passthrough_endpoints/pass_through_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
# exact byte/string body, such as AWS SigV4-signed requests.
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY = "litellm_pass_through_raw_body"

# Attribute set on the FastAPI endpoint function of every user-defined pass-through
# route. Auth reads it off the dispatched endpoint (``request.scope["endpoint"]``) to
# decide whether a request body ``model`` names an upstream model rather than a
# LiteLLM-managed one. Keying off the resolved endpoint (not the request path) means a
# custom path that collides with a built-in route never suppresses model-access checks:
# on a collision FastAPI dispatches the built-in handler, which does not carry this flag.
LITELLM_PASS_THROUGH_ENDPOINT_MARKER = "__litellm_pass_through_endpoint__"


class EndpointType(str, Enum):
VERTEX_AI = "vertex-ai"
Expand Down
82 changes: 82 additions & 0 deletions tests/test_litellm/proxy/auth/test_auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2047,6 +2047,88 @@ async def test_common_checks_metadata_route_keeps_key_tags_out_of_provider_metad
assert "metadata" not in request_body


def _pass_through_request() -> "Request":
"""A Request whose FastAPI-resolved endpoint carries the pass-through marker,
i.e. the request was dispatched to a user-defined pass-through handler."""
from fastapi import Request

from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
)

def pass_through_endpoint():
...

setattr(pass_through_endpoint, LITELLM_PASS_THROUGH_ENDPOINT_MARKER, True)
return Request(scope={"type": "http", "headers": [], "endpoint": pass_through_endpoint})


def _builtin_request() -> "Request":
"""A Request dispatched to a built-in (non-pass-through) handler, e.g. what a
custom path colliding with a core route actually resolves to."""
from fastapi import Request

def chat_completions():
...

return Request(scope={"type": "http", "headers": [], "endpoint": chat_completions})


@pytest.mark.asyncio
async def test_common_checks_auth_enforced_pass_through_ignores_upstream_model():
"""An auth-enforced (`auth: true`) user-defined pass-through endpoint must
authenticate the key but forward the body unchanged; a body `model` naming an
upstream-only model must not be rejected against the team/key model allowlist
when the request was dispatched to the pass-through handler. The same body on a
request dispatched to a built-in handler (e.g. a path collision) must still be
enforced."""
from litellm.proxy.auth.auth_checks import common_checks

team_object = LiteLLM_TeamTable(team_id="team-1", models=["gpt-4o"])
valid_token = UserAPIKeyAuth(
token="test-token",
team_id="team-1",
models=[],
metadata={"allowed_passthrough_routes": ["/my-custom-endpoint"]},
)

with patch(
"litellm.proxy.auth.auth_checks.get_tag_objects_batch",
new_callable=AsyncMock,
return_value={},
):
result = await common_checks(
request_body={"model": "upstream-special-model", "prompt": "hi"},
team_object=team_object,
user_object=None,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/my-custom-endpoint",
llm_router=None,
proxy_logging_obj=MagicMock(),
valid_token=valid_token,
request=_pass_through_request(),
)
assert result is True

with pytest.raises(ProxyException) as exc_info:
await common_checks(
request_body={"model": "upstream-special-model", "prompt": "hi"},
team_object=team_object,
user_object=None,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/v1/chat/completions",
llm_router=None,
proxy_logging_obj=MagicMock(),
valid_token=valid_token,
request=_builtin_request(),
)
assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied


@pytest.mark.asyncio
async def test_virtual_key_soft_budget_check_with_user_obj():
"""Test _virtual_key_soft_budget_check includes user_email when user_obj is provided"""
Expand Down
65 changes: 65 additions & 0 deletions tests/test_litellm/proxy/auth/test_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from unittest.mock import MagicMock, patch

import pytest
from fastapi import Request

from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import (
Expand Down Expand Up @@ -331,6 +332,70 @@ def test_should_fall_back_to_body_when_no_standard_header(self):
assert result == "body-user"


def _request_dispatched_to(endpoint) -> Request:
"""Build a minimal Request whose FastAPI-resolved endpoint is ``endpoint``,
mirroring what Starlette sets in ``scope`` once routing has matched."""
return Request(scope={"type": "http", "headers": [], "endpoint": endpoint})


def _pass_through_endpoint():
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
)

def endpoint(): # stand-in for create_pass_through_route's handler
...

setattr(endpoint, LITELLM_PASS_THROUGH_ENDPOINT_MARKER, True)
return endpoint


def test_get_model_from_request_skips_pass_through_dispatched_request():
"""When FastAPI dispatched the request to a user-defined pass-through handler,
the body `model` names an upstream model and must not be treated as a LiteLLM
model for allowlist/budget enforcement."""
assert (
get_model_from_request(
request_data={"model": "upstream-special-model"},
route="/my-custom-endpoint",
request=_request_dispatched_to(_pass_through_endpoint()),
)
is None
)


def test_get_model_from_request_enforces_when_builtin_handler_dispatched():
"""A custom pass-through path that collides with a built-in route resolves to the
built-in handler (no marker), so the body `model` must still be extracted and
enforced. Same request path as above, but dispatched to a non-pass-through
endpoint: the model must NOT be suppressed."""

def builtin_chat_completions():
...

assert (
get_model_from_request(
request_data={"model": "gpt-4o"},
route="/v1/chat/completions",
request=_request_dispatched_to(builtin_chat_completions),
)
== "gpt-4o"
)


def test_get_model_from_request_no_request_extracts_model():
"""Callers without a request object (e.g. budget reservation) still extract the
model; the pass-through suppression only applies to a dispatched pass-through
handler."""
assert (
get_model_from_request(
request_data={"model": "gpt-4o"},
route="/v1/chat/completions",
)
== "gpt-4o"
)


def test_get_model_from_request_supports_google_model_names_with_slashes():
assert (
get_model_from_request(
Expand Down
Loading